Prowadząc rekrutacje w poprzedniej firmie starałem się tak kierować rozmową, aby drążyć aspekty poruszane przez osobę rekrutowaną. Jednak jednej rzeczy nie mogłem odpuścić. Zawsze musiałem zapytać o to w jaki sposób dana osoba pisze testy jednostkowe. I w tej części często poruszanym konceptem przez osobę rekrutowaną był ArgumentCaptor. Przyznam otwarcie, że nigdy wcześniej nie słyszałem o czymś takim, ale podświadomie czułem, że wykorzystuję to w codziennej pracy. I jak się okazało, miałem rację! Tylko jeśli ktoś pisze testy w Spocku to robi to zapewne automatycznie. Natomiast w JUnit trzeba się trochę natrudnić, aby skorzystać z ArgumentCaptor. Zobaczmy to sobie na przykładzie.

Załóżmy, że robimy system do pożyczek. Aby dać komuś pożyczkę musimy znaleźć klienta w bazie, sprawdzić czy jest oszustem i jeśli nie to dopiero prosimy zewnętrzny system, aby wypłacił danej osobie pieniądze.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RequiredArgsConstructor
public class LendingMoney {

  private final FraudVerification fraudVerification;
  private final ClientRepository clientRepository;
  private final LendingExternalSystem lendingExternalSystem;

  public Result lend(ClientId clientId, BigDecimal amount) {
    Client client = clientRepository.getById(clientId);
    if (fraudVerification.isFraud(client.firstname(), client.lastname())) {
      return Result.Failure;
    }
    lendingExternalSystem.lendMoney(new LendingExternalSystem.LendingMoneyRequest(
        client.firstname(), client.lastname(), amount
    ));
    return Result.Success;
  }

}

Okej, skoro mamy gotowy przykład to zastanówmy się chwilę po co powstał ArgumentCaptor. Za jego pośrednictwiem możemy przechwytywać obiekt, który był niezbędny do wywołania metody konkretnego mocka. Po jego przechwyceniu jesteśmy w stanie pobrać z niego wartości i sprawdzić je w asercjach czy są faktycznie tym czego się spodziewaliśmy. W ten sposób mamy pewność, że wywowaliśmy daną metodę z poprawnymi wartościami.

JUnit’s way

Przetestujmy przypadek, w którym nasz klient przechodzi poprawnie weryfikację dotyczącą bycia oszustem. Sprawdźmy czy faktycznie w tym przypadku wywołamy zewnętrzny serwis z wartością pożyczki, którą klient sobie zażyczył.

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
28
29
30
31
32
class LendingMoneyTest {

  InMemoryClientRepository clientRepository = new InMemoryClientRepository();
  FraudVerification fraudVerification = Mockito.mock(FraudVerification.class);
  LendingExternalSystem lendingExternalSystem = Mockito.mock(LendingExternalSystem.class);

  LendingMoney lendingMoney = new LendingMoney(
      fraudVerification,
      clientRepository,
      lendingExternalSystem);

  @Test
  void verifyLendingMoneyToProperClient() {
    // given
    ClientId clientId = clientRepository.saveNew("Tomek", "Sojer");
    // and
    when(fraudVerification.isFraud(anyString(), anyString())).thenReturn(false);
    // and
    ArgumentCaptor<LendingMoneyRequest> requestCaptor = ArgumentCaptor.forClass(LendingMoneyRequest.class);

    // when
    lendingMoney.lend(clientId, BigDecimal.TWO);
    // then
    verify(lendingExternalSystem).lendMoney(requestCaptor.capture());
    // and
    LendingMoneyRequest capturedRequest = requestCaptor.getValue();
    assertThat(capturedRequest.firstName()).isEqualTo("Tomek");
    assertThat(capturedRequest.lastName()).isEqualTo("Sojer");
    assertThat(capturedRequest.amount()).isEqualTo(BigDecimal.TWO);
  }

}

W implementacji wykorzystującej ArgumentCaptor w JUnit widzę dwa potencjalne problemy. Pierwszym z nich jest to, że test jest przesiąknięty detalami technicznymi związanymi właśnie z ArgumentCaptor. A drugi problem wynika z pierwszego, bo test stał się bardziej obszerny zamiast bycia konkretnym. Trudno dostrzeć scenariusz, który jest weryfikowany.

Dobra, zobaczymy jak sprawy mają się w Spocku.

Spock’s way

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
28
29
30
class LendingMoneySpec extends Specification {
  
  InMemoryClientRepository clientRepository = new InMemoryClientRepository()
  FraudVerification fraudVerification = Mock(FraudVerification.class)
  LendingExternalSystem lendingExternalSystem = Mock(LendingExternalSystem.class)
  
  @Subject
  LendingMoney lendingMoney = new LendingMoney(
      fraudVerification,
      clientRepository,
      lendingExternalSystem)
  
  def "verify lending money to proper client"() {
    given:
      ClientId clientId = clientRepository.saveNew("Tomek", "Sojer")
    and:
      fraudVerification.isFraud(_ as String, _ as String) >> false
    
    when:
      lendingMoney.lend(clientId, BigDecimal.TWO)
    
    then:
      1 * lendingExternalSystem.lendMoney({
        assert it.firstName() == "Tomek"
        assert it.lastName() == "Sojer"
        assert it.amount() == BigDecimal.TWO
      })
  }
  
}

Tutaj sytuacja ma się znacznie lepiej. Test jest prosty i szybki w zrozumieniu. Nie ma żadnych technikaliów, można od razu dostrzec jaki scenariusz sprawdzamy.

Pod tym kątem Spock wygrywa zdecydowanie z JUnit. Jedyną moją bolączką jest współpraca Spocka z IntelliJ. Kiedy chcemy napisać linijkę taką jak assert it.firstName() == "Tomek" to niestety po wpisaniu it. IntelliJ nie podpowie czy czasem nie chodzi nam o firstName(), a to jest dosyć irytujące. Musimy sami ręcznie z palca wpisać nazwę zmiennej czy metody nie myląc się przy tym. Jeśli natomiast popełnimy błąd to dopiero po uruchomieniu dostaniemy informację zwrotną.

1
2
3
4
5
6
7
8
9
10
11
12
13
1 * lendingExternalSystem.lendMoney(LendingMoneyRequest[firstName=Tomek, lastName=Sojer, amount=2])
One or more arguments(s) didn't match:
0: Condition failed with Exception:
   
   it.firstName2() == "Tomek"
   |  |
   |  groovy.lang.MissingMethodException: No signature of method: pl.cezarysanecki.blogcode.argumentcaptor.LendingExternalSystem$LendingMoneyRequest.firstName2() is applicable for argument types: () values: []
   |  Possible solutions: firstName(), lastName()
   |    at pl.cezarysanecki.blogcode.argumentcaptor.LendingMoneySpec.$spock_feature_0_0_closure1(LendingMoneySpec.groovy:30)
   |    at org.spockframework.mock.constraint.CodeArgumentConstraint.describeMismatch(CodeArgumentConstraint.java:48)
   |    at org.spockframework.mock.constraint.PositionalArgumentListConstraint.describeMismatch(PositionalArgumentListConstraint.java:68)
   |    at org.spockframework.mock.runtime.MockInteraction.describeMismatch(MockInteraction.java:122)
   |    at org.spockframework.mock.runtime.InteractionScope$1.describeMismatch(InteractionScope.java:67)

Także coś za coś. W JUnit z takim problem się nie spotkamy.

Podsumowanie

Czasami może się zdarzyć, że korzystamy z czegoś nie mając świadomości jak to się formalnie nazywa. Jednak w takich sytuacjach istotna jest idea jaką się w danym momencie kierujemy. Coś co w jednym narzędziu jest instynktowane, w drugim może nie być już tak oczywiste. Dlatego warto słuchać innych, aby się rozwijać i poszerzać swoje horyzonty. Bo tak jak powiedział Dalai Lama:

“Kiedy mówisz, powtarzasz jedynie to, co już wiesz. Gdy zaś słuchasz, masz szansę nauczyć się czegoś nowego”