Testowanie wyjątków

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 atrybut expected, w którym podaje się klasę spodziewanego wyjątku. Test przechodzi, gdy wyjątek zostanie rzucony.
  • 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:

    • 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.
  • 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.

  • Antony Marcano w swoim poście prezentuje ciekawe urozmaicenie powyższego podejścia. Przy okazji pokazuje kilka możliwości biblioteki Hamcrest.
  • 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().

  • JUnit od wersji 4.7 udostępnia adnotację @Rule oraz ExpectedException. Więcej na ten temat w dokumentacji.
  • 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
        }
    }
  • Niedawno pojawiła się ciekawa biblioteka catch-exception. Test w stylu BDD z jej pomocą wyglądałby tak:
  • 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();
        }
    }
  • 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
    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:

Kategorie java, tdd.
Tagi , , , , , , , , .

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *