Към съдържанието
wannacode

Тестове с JUnit

Проблем

Отстъпката, която се счупи тихо

Правилото на магазина е: отстъпката е в проценти, а при покупка от 100 € нагоре се добавят още 5%. Пишеш метода и го пробваш на ръка:

Main.java
class Discount {
// Отстъпка в проценти. От 100 € нагоре се добавят още 5%.
static double apply(double price, int percent) {
if (price > 100) {
percent = percent + 5;
}
return price * (100 - percent) / 100;
}
}
System.out.println(Discount.apply(50, 10));
System.out.println(Discount.apply(200, 10));
Изход
45.0
170.0

50 € с 10% са 45 €, а 200 € с 15% са 170 €. Изглежда наред и го пускаш. След седмица клиент с покупка за точно 100 € плаща 90 € вместо 85 €. Условието е price > 100, а трябва да е price >= 100.

Никой не е пробвал точно 100 €. А при всяка следваща промяна в метода трябва да пробваш пак всички случаи на ръка.

Идеята

Код, който проверява код

Вместо да гледаш изхода с очите си, пишеш малки методи, които викат твоя код и сравняват резултата с очаквания. Това е тест. Пускаш всички тестове с едно натискане, след всяка промяна, и те казват кое се е счупило.

Вид Какво проверява Пример
Unit тест едно малко парче, обикновено един метод, отделно от останалото Discount.apply(100, 10) връща 85
Интеграционен как парчетата работят заедно: с база данни, файл, друга услуга поръчката се записва в базата и се чете обратно
End-to-end цялото приложение, както го ползва човек клиентът слага в количката, плаща и получава имейл
В този урок пишем unit тестове. В Java за тях се ползва библиотеката JUnit.

Защо си струва:

  • грешката се вижда веднага, докато още помниш какво си променил, а не когато клиентът се оплаче;
  • тестът показва как се ползва кодът: кой случай какво връща;
  • можеш да подреждаш кода смело. Ако тестовете са зелени и след промяната, поведението не се е променило.
По-точно, за любопитните

Има и други видове тестове: функционални (отговаря ли програмата на изискванията), за производителност (колко заявки издържа), за сигурност (има ли пробиви) и на потребителския интерфейс (бутоните, формите). Всички те стъпват на unit тестовете: те са най-много, най-бързи и най-евтини за поддържане.

Виж го

Първият тест

Тестовете за Discount са в отделен клас DiscountTest:
DiscountTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DiscountTest {
@Test
void tenPercentOff() {
assertEquals(45.0, Discount.apply(50, 10));
}
@Test
void extraFivePercentFrom100() {
assertEquals(85.0, Discount.apply(100, 10));
}
}
  • @Test пред метода казва на JUnit „това е тест, пусни го“. Такива етикети с @ се казват анотации. Вече си виждал @Override. Как JUnit ги намира, ще видим в последния урок на модула.
  • assertEquals(очаквано, истинско) сравнява двете стойности. Ако се различават, тестът пада.

Пускаме двата теста срещу кода от първата карта:

Резултат
ОК tenPercentOff()
ПАДА extraFivePercentFrom100(): expected: <85.0> but was: <90.0>
Тестове: 2, минали: 1, паднали: 1

Първият тест минава. Вторият пада и казва точно какво е станало: очаквал е 85.0, а методът е върнал 90.0. Поправяш условието на price >= 100, пускаш пак и двата са зелени:

Резултат
ОК extraFivePercentFrom100()
ОК tenPercentOff()
Тестове: 2, минали: 2, паднали: 0

Всеки тест прави три неща: подготвя стойностите, вика кода и проверява резултата.

class DiscountTest {
@Test
void tenPercentOff() {
assertEquals(45.0, Discount.apply(50, 10));
}
@Test
void extraFivePercentFrom100() {
assertEquals(85.0, Discount.apply(100, 10));
}
}
Стъпка по стъпка, с думи
  1. DiscountTest има два теста. JUnit пуска всеки поотделно и пали лампичка до него.
  2. Тест 1, подготовка: цена 50 € и 10% отстъпка.
  3. Действие: Discount.apply(50, 10) връща 45.0.
  4. Проверка: assertEquals сравнява очакваното 45.0 с върнатото 45.0. Еднакви са, тестът минава.
  5. Тест 2: точно 100 € и 10%. Очакваме 85.0, защото от 100 € нагоре има още 5%.
  6. Условието в метода е price > 100, а 100 не е по-голямо от 100. Методът връща 90.0.
  7. Очаквано 85.0, върнато 90.0: тестът пада и казва точно това.
  8. Поправяш условието на price >= 100 и пускаш тестовете пак.
  9. Сега 100 минава условието и методът връща 85.0.
  10. Очаквано и върнато съвпадат. И двата теста са зелени.
По-точно, за любопитните
  • В истински проект тестовете са в src/test/java, до кода в src/main/java. IntelliJ показва зелена стрелка до всеки тест и до класа: натискаш я и виждаш резултата.
Структура
src/main/java/Discount.java кодът
src/test/java/DiscountTest.java тестовете за него
  • JUnit се добавя като зависимост. С Maven това е в pom.xml, а тестовете се пускат с mvn test:
pom.xml
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
  • Тук, на сайта, тестовете се пускат от main през програмния интерфейс на JUnit (Launcher API). Методът по-долу пуска класовете и печата резултатите, подредени по име. Подредени са, защото JUnit не обещава реда на тестовете:
Пускачът
static void runTests(Class<?>... classes) {
LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request()
.selectors(Arrays.stream(classes).map(DiscoverySelectors::selectClass).toList())
.build();
List<String> lines = new ArrayList<>();
int[] failed = { 0 };
TestExecutionListener listener = new TestExecutionListener() {
@Override
public void executionFinished(TestIdentifier id, TestExecutionResult result) {
if (!id.isTest()) return;
if (result.getStatus() == TestExecutionResult.Status.SUCCESSFUL) {
lines.add("ОК " + id.getDisplayName());
} else {
failed[0]++;
lines.add("ПАДА " + id.getDisplayName() + ": " + result.getThrowable().map(Throwable::getMessage).orElse(""));
}
}
};
LauncherFactory.create().execute(request, listener);
Collections.sort(lines);
lines.forEach(System.out::println);
System.out.println("Тестове: " + lines.size() + ", минали: " + (lines.size() - failed[0]) + ", паднали: " + failed[0]);
}

Пробвай

ДДС с double

Цената с ДДС е цената по 1,2. Два теста:

Какво ще покажат тестовете?

class Vat {
static double withVat(double price) {
return price * 1.2;
}
}
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class VatTest {
@Test
void coffee() {
assertEquals(12.0, Vat.withVat(10));
}
@Test
void tea() {
assertEquals(3.6, Vat.withVat(3));
}
}
Отговор

Идеята

Да или не, и очаквано изключение

Освен равенство често проверяваш условие или че кодът отказва. Ето Account от модула за изключенията, с нов метод canPay:

Account.java
class InsufficientFundsException extends RuntimeException {
public InsufficientFundsException(int shortfall) {
super("Не стигат " + shortfall + " €");
}
}
class Account {
private int balance;
public Account(int balance) {
this.balance = balance;
}
public int getBalance() {
return balance;
}
public boolean canPay(int amount) {
return amount <= balance;
}
public void withdraw(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Сумата трябва да е положителна");
}
if (amount > balance) {
throw new InsufficientFundsException(amount - balance);
}
balance -= amount;
}
}
AccountTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class AccountTest {
@Test
void canPayWhatIsThere() {
Account account = new Account(100);
assertTrue(account.canPay(100));
assertFalse(account.canPay(101));
}
@Test
void withdrawTooMuch() {
Account account = new Account(100);
InsufficientFundsException e = assertThrows(InsufficientFundsException.class,
() -> account.withdraw(130));
assertEquals("Не стигат 30 €", e.getMessage());
assertEquals(100, account.getBalance());
}
}
Изход
ОК canPayWhatIsThere()
ОК withdrawTooMuch()
Тестове: 2, минали: 2, паднали: 0
  • assertTrue и assertFalse проверяват условие.
  • assertThrows(вид.class, код) пада, ако кодът не хвърли изключение от този вид. Кодът се подава като ламбда: assertThrows сам го пуска и хваща изключението. После го връща, за да провериш и съобщението.

Вторият тест проверява и нещо, което лесно се забравя: след отказа балансът е същият.

Идеята

Всеки тест започва на чисто

Тук двата теста делят една сметка от 100 €:

AccountTest.java
class AccountTest {
private static Account account = new Account(100);
@Test
@DisplayName("теглене на 40 € оставя 60 €")
void withdraw40() {
account.withdraw(40);
assertEquals(60, account.getBalance());
}
@Test
@DisplayName("теглене на 70 € оставя 30 €")
void withdraw70() {
account.withdraw(70);
assertEquals(30, account.getBalance());
}
}
Изход
ОК теглене на 40 € оставя 60 €
ПАДА теглене на 70 € оставя 30 €: Не стигат 10 €
Тестове: 2, минали: 1, паднали: 1

JUnit е пуснал първо теста за 40 €. На втория са останали 60 €, а той тегли 70 €. Ако редът беше обратният, щеше да падне другият. Тест, който зависи от друг, е капан. Затова обектът се създава наново преди всеки тест с @BeforeEach:

AccountTest.java
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class AccountTest {
private Account account;
@BeforeEach
void newAccount() {
account = new Account(100);
}
@Test
@DisplayName("теглене на 40 € оставя 60 €")
void withdraw40() {
account.withdraw(40);
assertEquals(60, account.getBalance());
}
@Test
@DisplayName("теглене на 70 € оставя 30 €")
void withdraw70() {
account.withdraw(70);
assertEquals(30, account.getBalance());
}
}
Изход
ОК теглене на 40 € оставя 60 €
ОК теглене на 70 € оставя 30 €
Тестове: 2, минали: 2, паднали: 0
@DisplayName дава на теста име с думи. То се показва в резултата вместо името на метода.
По-точно, за любопитните
Още анотации, които ще срещнеш:
Анотации
@AfterEach след всеки тест (почистване)
@BeforeAll веднъж преди всички тестове в класа (static метод)
@AfterAll веднъж след всички (static метод)
@Disabled("...") тестът временно не се пуска
@Timeout(5) тестът пада, ако продължи повече от 5 секунди
@Nested вложен клас с група свързани тестове
@Tag("fast") етикет, за да пуснеш само част от тестовете
@RepeatedTest(5) същият тест 5 пъти
@ValueSource(ints = { 1, 2, 3 }) по една стойност за параметризиран тест
@CsvFileSource(resources = "/data.csv") редовете идват от файл
@MethodSource("prices") аргументите идват от static метод
@TempDir Path dir временна папка, която се трие след теста
@ExtendWith(...), @RegisterExtension разширения на JUnit
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
имената се правят от имената на методите

Идеята

Една проверка, много случаи

Четири случая за отстъпката значат четири почти еднакви метода. @ParameterizedTest пуска един метод с различни стойности, а @CsvSource ги дава като редове „цена, процент, очаквано“:

DiscountTest.java
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DiscountTest {
@ParameterizedTest(name = "{0} € с {1}% = {2} €")
@CsvSource({
"50, 10, 45.0",
"99, 0, 99.0",
"100, 10, 85.0",
"200, 20, 150.0"
})
void apply(double price, int percent, double expected) {
assertEquals(expected, Discount.apply(price, percent));
}
}
Изход
ОК 100 € с 10% = 85.0 €
ОК 200 € с 20% = 150.0 €
ОК 50 € с 10% = 45.0 €
ОК 99 € с 0% = 99.0 €
Тестове: 4, минали: 4, паднали: 0

Всеки ред е отделен тест с отделен резултат. name в @ParameterizedTest прави името му от стойностите: {0} е първата, {1} втората. Нов случай е нов ред, не нов метод. Важните случаи са на границите: точно 100 €, 99 €, 0%.

Пробвай

Намери грешката по теста

Доставката е 4 €, а от 50 € нагоре е безплатна. Тестът:

DeliveryTest.java
class DeliveryTest {
@ParameterizedTest(name = "поръчка за {0} €: доставка {1} €")
@CsvSource({ "20, 4", "50, 0", "80, 0" })
void price(int total, int expected) {
assertEquals(expected, Delivery.price(total));
}
}
Изход
ОК поръчка за 20 €: доставка 4 €
ОК поръчка за 80 €: доставка 0 €
ПАДА поръчка за 50 €: доставка 0 €: expected: <0> but was: <4>
Тестове: 3, минали: 2, паднали: 1

На кой ред в кода е грешката?

Избери реда с грешката.

Идеята

Първо тестът

Касата трябва да приема промокод ESEN10 за 10%, без значение дали е с малки или главни букви. Този път пишеш тестовете първо:

PromoTest.java
class PromoTest {
@Test
void knownCode() {
assertEquals(10, Promo.percent("ESEN10"));
}
@Test
void codeIsCaseInsensitive() {
assertEquals(10, Promo.percent("esen10"));
}
@Test
void unknownCode() {
assertEquals(0, Promo.percent("NIKAKAV"));
}
}
  1. Червено. Методът засега връща 0. Тестовете падат, и това е добре: значи наистина проверяват нещо.
class Promo {
static int percent(String code) {
return 0;
}
}
Изход
ОК unknownCode()
ПАДА codeIsCaseInsensitive(): expected: <10> but was: <0>
ПАДА knownCode(): expected: <10> but was: <0>
Тестове: 3, минали: 1, паднали: 2
  1. Зелено. Пишеш най-простия код, с който минават:
class Promo {
static int percent(String code) {
if (code.equalsIgnoreCase("ESEN10")) {
return 10;
}
return 0;
}
}
Изход
ОК codeIsCaseInsensitive()
ОК knownCode()
ОК unknownCode()
Тестове: 3, минали: 3, паднали: 0
  1. Подреждане. Сега можеш да изнесеш кодовете в HashMap, да добавиш нови и да подредиш метода. Тестовете ще кажат, ако счупиш нещо.

Това е TDD (test-driven development, разработка, водена от тестовете). Тестът първо те кара да решиш какво точно трябва да прави методът, преди да мислиш как.

По-точно

Сюит и фалшиви обекти

Сюит е група тестови класове, които се пускат заедно, например всички тестове на касата:

CheckoutSuite.java
import org.junit.platform.suite.api.SelectClasses;
import org.junit.platform.suite.api.Suite;
import org.junit.platform.suite.api.SuiteDisplayName;
@Suite
@SuiteDisplayName("Тестовете на касата")
@SelectClasses({ DiscountTest.class, AccountTest.class, DeliveryTest.class })
class CheckoutSuite {
}

Пуснат така, той изпълнява тестовете на трите класа.

По-точно, за любопитните
  • Понякога кодът зависи от нещо, което в тест не искаш да викаш: курс на валута от банка, плащане, имейл. Тогава подаваш фалшив обект (mock), който връща каквото му кажеш. Най-известната библиотека за това е Mockito:
PriceInUsdTest.java
import static org.mockito.Mockito.*;
interface ExchangeRates {
double eurTo(String currency);
}
class PriceInUsdTest {
@Test
void convertsWithTodaysRate() {
ExchangeRates rates = mock(ExchangeRates.class);
when(rates.eurTo("USD")).thenReturn(1.10);
Converter converter = new Converter(rates);
assertEquals(11.0, converter.toUsd(10), 0.001);
verify(rates).eurTo("USD");
}
}

mock прави обект, който изпълнява интерфейса, when(...).thenReturn(...) казва какво да връща, а verify проверява, че методът е бил извикан. Тук на сайта Mockito само се компилира: за да работи, му трябва още една библиотека (byte-buddy).

  • Още проверки освен assertEquals, assertTrue и assertThrows:
Проверки
assertNotEquals(a, b) различни ли са
assertSame(a, b) един и същ обект ли е (==), не просто равни
assertNotSame(a, b)
assertNull(x), assertNotNull(x)
assertArrayEquals(a, b) масивите имат еднакви елементи
assertIterableEquals(a, b) два списъка с еднакви елементи в същия ред
assertLinesMatch(a, b) редове текст (позволява и регулярни изрази)
assertAll(() -> ..., () -> ...) пуска всички проверки и съобщава всички грешки
assertTimeout(Duration.ofMillis(100), () -> ...)
fail("съобщение") тестът пада веднага

Упражнение

Напиши тестовете

Реши задачите в редактора си, с JUnit 5 в проекта.

1. Калкулаторът на отстъпки. Напиши DiscountTest за Discount.apply от тази карта. Покрий тези случаи: 80 € с 10% са 72 €, 100 € с 0% са 95 €, 150 € с 20% са 112,50 €, 0 € с 30% са 0 € и точно 100 € с 10% са 85 €. Пусни ги и срещу счупената версия с price > 100. Трябва да паднат точно двата случая с 100 €:

Резултат срещу счупената версия
ОК 0 € с 30% = 0.0 €
ОК 150 € с 20% = 112.5 €
ОК 80 € с 10% = 72.0 €
ПАДА 100 € с 0% = 95.0 €: expected: <95.0> but was: <100.0>
ПАДА exactly100GetsTheExtra(): expected: <85.0> but was: <90.0>
Тестове: 5, минали: 3, паднали: 2

2. Сметката. Напиши AccountTest за Account от картата „Да или не, и очаквано изключение“. Създавай сметката с 100 € в @BeforeEach. Провери, че:

  • теглене на 30 € оставя 70 €;
  • може да се изтегли всичко и остава 0;
  • теглене на 150 € хвърля InsufficientFundsException със съобщение „Не стигат 50 €“, а балансът остава 100 €;
  • 0 и -20 € хвърлят IllegalArgumentException (един параметризиран тест с @ValueSource(ints = { 0, -20 })).

3. Бъгът в средната оценка. Методът изглежда наред:

Grades.java
class Grades {
static double average(int[] grades) {
int sum = 0;
for (int g : grades) {
sum += g;
}
return sum / grades.length;
}
}
Напиши два теста: средното на 4 и 6 е 5,0, а средното на 5 и 6 е 5,5 (с допустима разлика 0,001). Единият пада:
Резултат
ОК wholeAverage()
ПАДА averageWithAFraction(): expected: <5.5> but was: <5.0>
Тестове: 2, минали: 1, паднали: 1

Намери причината и поправи метода, така че и двата да минат.

Тест

Провери се

1. Какво проверява един unit тест?
2. Тестът пада със „expected: <85.0> but was: <90.0>“. Колко върна методът?
3. Защо сметката се създава в @BeforeEach, а не веднъж в поле?
4. Как проверяваш, че withdraw(130) хвърля InsufficientFundsException?
5. Кой е редът при TDD?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·