Mam problem z mockami… Głównie z tymi dotyczącymi repozytoriów od Spring Data (np. CrudRepository). Zobaczmy sobie to na naprawdę trywialnym przykładzie.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
internal class Customer(
  var firstName: String,
  var lastName: String
) {

  @Id
  @GeneratedValue(strategy = GenerationType.SEQUENCE)
  var id: Long? = null

  constructor(id: Long, firstName: String, lastName: String) : this(firstName, lastName) {
    this.id = id
  }

}
1
2
interface CustomerRepository extends CrudRepository<Customer, Long> {
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
open class CustomerFacade internal constructor(
  private val customerRepository: CustomerRepository
) {

  @Transactional
  open fun addNewCustomer(firstName: String, lastName: String): Long {
    val customer = customerRepository.save(Customer(firstName, lastName))
    return customer.id ?: throw RuntimeException("Customer was not created")
  }

  @Transactional
  open fun updateNames(customerId: Long, firstName: String, lastName: String) {
    val customer = customerRepository.findById(customerId)
      .orElseThrow { IllegalArgumentException("Customer with id $customerId does not exist.") }

    customer.firstName = firstName
    customer.lastName = lastName
  }

}

Chcielibyśmy teraz przetestować czy imię i nazwisko naszego klienta się zmieni. Zaprzęgnijmy do tego mocki.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class CustomerFacadeWithMockTest {

  private val customerRepository: CustomerRepository = Mockito.mock(CustomerRepository::class.java)

  private val customerFacade: CustomerFacade = CustomerFacade(customerRepository)

  @Test
  fun updatingNames() {
    // given
    val id = 12L
    val firstName = "Kazik"
    val lastName = "Mazik"
    // and
    val customer = Customer(id, firstName, lastName)
    Mockito.`when`(customerRepository.save(Mockito.any())).thenReturn(customer)
    Mockito.`when`(customerRepository.findById(id)).thenReturn(Optional.of(customer))
    // and
    val customerId = customerFacade.addNewCustomer(firstName, lastName)

    // when
    customerFacade.updateNames(customerId, "Mateusz", "Burczymuch")

    // then
    // ???
  }

}

I co teraz? W jaki sposób sprawdzić, że imię i nazwisko się zmieniło? Można by kombinować, że metoda updateNames zwracałaby jakiś obiekt DTO czy coś. Pewnie miałoby to sens z racji właśnie testów. Ale abstrahując od tego, ile trzeba napisać kodu w teście, aby przetestować tą funkcjonalność… Czytelność samej metody testowej pozostawia wiele do życzenia. A gdyby podejść do tego inaczej?

Wykorzystajmy fake w postaci InMemoryRepository

Zamiast mocka możemy skorzystać z jakże pożytecznej i praktycznej bazy danych. Mowa tutaj o bazie danych w pamięci np. w postaci mapy. Mogłaby ona wyglądać tak.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class InMemoryCustomerRepository implements CustomerRepository {

  private static final Map<Long, Customer> DATABASE = new ConcurrentHashMap<>();
  private static final AtomicLong ID_GENERATOR = new AtomicLong();

  @Override
  public <S extends Customer> S save(S entity) {
    if (entity.getId() == null) {
      entity.setId(ID_GENERATOR.incrementAndGet());
    }
    DATABASE.put(entity.getId(), entity);
    return entity;
  }

  @Override
  public Optional<Customer> findById(Long id) {
    return Optional.ofNullable(DATABASE.get(id));
  }

  @Override
  public <S extends Customer> Iterable<S> saveAll(Iterable<S> entities) {
    throw new UnsupportedOperationException();
  }

  // other methods

}

Ktoś mógłby się w tym miejscu obruszyć i powiedzieć, że oszukuję, bo nie dostarczam prawdziwej implementacji do wszystkich metod obecnych w CrudRepository. I w sumie miałby rację. Zrobiłem tak na potrzeby tego wpisu. Ale z drugiej strony, czy to nie jest prawidłowa implementacja? Po prostu te metody nie są mi potrzebne, więc rzucam wyjątek, aby w przyszłości, gdy zostaną użyte, je po prostu zaimplementować.

Oczywiście można to by też wyciągnąć do implementacji generycznego repozytorium w pamięci, ale to już zostawiam jako zadanie domowe. Sprawdźmy jak wykorzystanie InMemoryCustomerRepository wygląda w teście.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class CustomerFacadeWithInMemTest {

  private val customerRepository: CustomerRepository = InMemoryCustomerRepository()

  private val customerFacade: CustomerFacade = CustomerFacade(customerRepository)

  @Test
  fun updatingNames() {
    // given
    val newFirstName = "Mateusz"
    val newLastName = "Burczymuch"
    // and
    val customerId = customerFacade.addNewCustomer("Kazik", "Mazik")

    // when
    customerFacade.updateNames(customerId, newFirstName, newLastName)

    // then
    val result = customerRepository.findById(customerId).get()
    assertEquals(newFirstName, result.firstName)
    assertEquals(newLastName, result.lastName)
  }

}

Test przechodzi posiadając najważniejszą część, czyli asercję! Pewnie nie jest to perfekcyjne rozwiązanie, ale dla mnie się podoba. Jest schludne, bez jakichkolwiek technicznych linijek kodu jak uczenie mocka odpowiedzi. Skupiamy się tylko i wyłącznie na tym jak powinno działać wymaganie biznesowe.

A gdyby kod się zmienił…?

Zastanówmy się co takie podejście jeszcze nam daje. Spróbujmy przenieść rzucenie wyjątku o nieznalezionym kliencie do interfejsu repozytorium.

1
2
3
4
5
6
7
8
interface CustomerRepository extends CrudRepository<Customer, Long> {

  default Customer getById(Long customerId) {
    return findById(customerId)
        .orElseThrow(() -> new IllegalArgumentException("Customer with id $id not found"));
  }

}

I użyjmy go w fasadzie.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
open class CustomerFacade internal constructor(
  private val customerRepository: CustomerRepository
) {

  @Transactional
  open fun addNewCustomer(firstName: String, lastName: String): Long {
    val customer = customerRepository.save(Customer(firstName, lastName))
    return customer.id ?: throw RuntimeException("Customer was not created")
  }

  @Transactional
  open fun updateNames(customerId: Long, firstName: String, lastName: String) {
    val customer = customerRepository.getById(customerId)

    customer.firstName = firstName
    customer.lastName = lastName
  }

}

Załóżmy też, że w jakiś sposób napisaliśmy asercję w teście z mockiem. Jeśli go w tym momencie uruchomimy dostaniemy taki oto komunikat.

java.lang.NullPointerException: Cannot invoke “pl.cezarysanecki.blogcode.mockproblem.Customer.setFirstName(String)” because “customer” is null

Dlaczego? Z racji tej linijki kodu.

1
Mockito.`when`(customerRepository.findById(id)).thenReturn(Optional.of(customer))

Robiąc taką zmianę w kodzie produkcyjnym musielibyśmy teraz nauczyć naszego mocka jak ma się zachowywać przy wywołaniu nowej metody. Zagłębiamy się w szczegóły implementacyjne, w detale które nie powinny nas za bardzo interesować z punktu widzenia testu. Wyobraźmy sobie sytuacje, w której napisaliśmy 100 testów do funkcjonalności wykorzystujących findBy i, w których zaszła zmiana na getBy. Boli prawda?

Sprawdźmy zatem jak poradził sobie test wykorzystujący repozytorium będące w pamięci. Uruchamiamy test i on przeszedł! Co prawda w sekcji asercji wyciągamy klienta wykorzystując metodę findBy, ale zmiana w kodzie produkcyjnym nas nie dotknęła. Chętni mogą oczywiście zmienić to wykorzystanie na getBy, ale nie ma takiej potrzeby. Po prostu nie zagłębiamy się w to jak dana funkcjonalność wewnętrznie działa. Asercja sprawdza obserwowalne zachowanie poprzez weryfikację czy w bazie danych klient ma nowe imię i nazwisko.

Podsumowanie

Tak jak napisałem w tytule, mam problem z mockami. Mam nadzieję, że ten artykuł pokazał dlaczego. Oczywiście nie tyczy się to tylko repozytoriów, ale też portów czy innych zewnętrznych zależności. Moim zdaniem lepiej jest napisać “fałszywą” implemetację niż męczyć się z mockami. Dzięki temu jesteśmy bardziej odporni na zmiany oraz testy są przyjemniejsze w odbiorze. Co prawda okupujemy to większą ilością kodu, ale paradoksalnie spowoduje to, że tego kodu w testach będziemy musieli napisać mniej.