Ostatnio kilka osób pytało mnie, jak testować metody, które deklarują rzucanie wyjątków. Postanowiłem dokładniej przyjrzeć się tematowi.
Na potrzeby przykładów utworzyłem prostą klasę Account
, w której zdefiniowałem kilka metod. Nie należy traktować jej jako przykładu implementacji operacji na koncie, tym bardziej w środowisku wielowątkowym.
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 33 34 35 36 37 38 39 40 41 42 43 | public class Account { private final String name; private int balance = 0; public Account(final String name) { this.name = name; } public String getName() { return name; } public int getBalance() { return balance; } public int deposit(final int amount) { return balance += amount; } public int withdraw(final int amount) throws NotEnoughMoneyException { if (amount > balance) { throw new NotEnoughMoneyException(this, amount); } return balance -= amount; } @Override public String toString() { return "Account{" + "name='" + name + "'" + ", balance=" + balance + "}"; } } public class NotEnoughMoneyException extends Exception { public NotEnoughMoneyException(final Account account, final int amount) { super(account + " cannot withdraw " + amount); } } |
Poniżej przedstawię i omówię kilka sposobów testowania metody withdraw()
pod kątem pojawienia się oczekiwanego wyjątku NotEnoughMoneyException
podczas próby wypłaty kwoty większej niż saldo. Założenie jest takie, że konto nie obsługuje debetów.
- JUnit od wersji 4 oferuje adnotację
@Test
oraz atrybutexpected
, w którym podaje się klasę spodziewanego wyjątku. Test przechodzi, gdy wyjątek zostanie rzucony. - nie można sprawdzić poprawności treści wyjątku,
- jeśli z jakichś przyczyn w sekcji
given
poleci oczekiwany wyjątek (lub jego podtyp), to test będzie przechodził, a testowana metoda nie zostanie nawet wywołana! - Kolejnym sposobem testowania wyjątków jest podejście tradycyjne z użyciem konstrukcji
try-catch
. - Antony Marcano w swoim poście prezentuje ciekawe urozmaicenie powyższego podejścia. Przy okazji pokazuje kilka możliwości biblioteki Hamcrest.
- JUnit od wersji 4.7 udostępnia adnotację
@Rule
orazExpectedException
. Więcej na ten temat w dokumentacji. - Niedawno pojawiła się ciekawa biblioteka catch-exception. Test w stylu BDD z jej pomocą wyglądałby tak:
- Jeśli testujemy metodę, która woła metodę (deklarującą rzucanie wyjątku) z klasy zamockowanej na potrzeby testu, możemy użyć Mockito. Poniższa klasa
Shop
pokazuje taką sytuację.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | import org.junit.Test; public class AccountTest { @Test(expected = NotEnoughMoneyException.class) public void shouldThrowsNotEnoughMoneyException() throws Exception { // given final Account account = new Account("Rafos"); account.deposit(100); // when account.withdraw(200); // then fail("attempt to withdraw too much money should throw an exception"); // optional } } |
Jest to najprostszy sposób testowania wyjątków. Należy jednak zwrócić uwagę, aby nigdy, przenigdy nie wpisywać Exception.class
do atrybutu expected
, gdyż taki test staje się bezużyteczny; powinno podawać się dokładnie taki wyjątek, jakiego się spodziewamy. Ponadto powyższy test ma pewne wady:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | import org.junit.Test; import static org.junit.Assert.*; public class AccountTest { @Test public void shouldThrowNotEnoughMoneyException() throws Exception { // given final Account account = new Account("Rafos"); account.deposit(100); try { // when account.withdraw(200); fail("attempt to withdraw too much money should throw an exception"); } catch (NotEnoughMoneyException e) { // then } } } |
Można nieco rozszerzyć test i sprawdzić, czy faktycznie po nieudanej wypłacie pieniędzy stan konta pozostał taki sam oraz czy message
w wyjątku zawiera odpowiednią wartość.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | import org.junit.Test; import static org.junit.Assert.*; public class AccountTest { @Test public void shouldThrowNotEnoughMoneyException() throws Exception { // given final Account account = new Account("Rafos"); account.deposit(100); try { // when account.withdraw(200); fail("attempt to withdraw too much money should throw an exception"); } catch (NotEnoughMoneyException e) { // then assertEquals(100, account.getBalance()); assertTrue(e.getMessage().contains("cannot withdraw")); } } } |
Najważniejszą kwestią jest to, aby w sekcji try
(czyli when
) występowała tylko jedna instrukcja – wywołanie testowanej metody.
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 | import org.junit.Test; import static org.hamcrest.CoreMatchers.instanceOf; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.text.StringStartsWith.startsWith; public class AccountTest { @Test public void shouldThrowNotEnoughMoneyException() throws Exception { // given Exception thrown = null; final Account account = new Account("Rafos"); account.deposit(100); try { // when account.withdraw(200); } catch (Exception caught) { thrown = caught; } // then assertThat(thrown, is(instanceOf(NotEnoughMoneyException.class))); assertThat(thrown.getMessage(), startsWith("Account{name='Rafos'")); } } |
Kod wygląda przejrzyściej a asercje wyciągnięte są poza sekcję catch
. Dzięki temu nie trzeba używać fail()
.
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 | import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; import static org.hamcrest.text.StringStartsWith.startsWith; import static org.junit.Assert.fail; public class AccountTest { @Rule public ExpectedException thrown = ExpectedException.none(); @Test public void shouldThrowsNotEnoughMoneyException() throws Exception { // given thrown.expect(NotEnoughMoneyException.class); thrown.expectMessage("cannot withdraw 200"); thrown.expectMessage(startsWith("Account{name='Rafos'")); final Account account = new Account("Rafos"); account.deposit(100); // when account.withdraw(200); // then fail("attempt to withdraw too much money should throw an exception"); // optional } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | import org.junit.Test; import static com.googlecode.catchexception.CatchException.*; import static com.googlecode.catchexception.apis.CatchExceptionBdd.*; public class AccountTest { @Test public void shouldThrowNotEnoughMoneyException() throws Exception { // given final Account account = new Account("Rafos"); account.deposit(100); // when when(account).withdraw(200); // then then(caughtException()) .isExactlyInstanceOf(NotEnoughMoneyException.class) .hasMessage("Account{name='Rafos', balance=100} cannot withdraw 200") .hasNoCause(); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | public class Shop { private Account account; public Shop(final Account account) { this.account = account; } public boolean buy(final int productId) { try { // other stuff final int price = getPrice(productId); account.withdraw(price); return true; } catch (NotEnoughMoneyException e) { return false; } } } |
Kod testowy metody buy()
mógłby wyglądać następująco:
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 | import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.runners.MockitoJUnitRunner; import static org.junit.Assert.assertFalse; import static org.mockito.Matchers.anyInt; import static org.mockito.Mockito.when; @RunWith(MockitoJUnitRunner.class) public class ShopTest { @Mock private Account mock; @InjectMocks private Shop sut = new Shop(null); @Test public void shouldNotBuyIfExceptionOccurs() throws Exception { // given when(mock.withdraw(anyInt())).thenThrow(new NotEnoughMoneyException(null, anyInt())); // when final boolean isPurchaseSuccessful = sut.buy(1123456); // then assertFalse(isPurchaseSuccessful); } } |
Zainteresowanych TDD i Mockito odsyłam do prezentacji Bartosza Bańkowskiego i Szczepana Fabera pt. „Pokochaj swoje testy„. Natomiast moli książkowych zachęcam do lektury poniższych książek:
- Growing Object-Oriented Software, Guided by Tests Steve’a Freemana i Nata Pryce’a
- xUnit Test Patterns: Refactoring Test Code Gerarda Meszarosa
- Practical Unit Testing Tomka Kaczanowskiego