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”