Тестове с JUnit
Проблем
Отстъпката, която се счупи тихо
Правилото на магазина е: отстъпката е в проценти, а при покупка от 100 € нагоре се добавят още 5%. Пишеш метода и го пробваш на ръка:
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.0170.050 € с 10% са 45 €, а 200 € с 15% са 170 €. Изглежда наред и го пускаш. След седмица клиент с покупка за точно 100 € плаща 90 € вместо 85 €. Условието е price > 100, а трябва да е price >= 100.
Никой не е пробвал точно 100 €. А при всяка следваща промяна в метода трябва да пробваш пак всички случаи на ръка.
Идеята
Код, който проверява код
Вместо да гледаш изхода с очите си, пишеш малки методи, които викат твоя код и сравняват резултата с очаквания. Това е тест. Пускаш всички тестове с едно натискане, след всяка промяна, и те казват кое се е счупило.
| Вид | Какво проверява | Пример |
|---|---|---|
| Unit тест | едно малко парче, обикновено един метод, отделно от останалото | Discount.apply(100, 10) връща 85 |
| Интеграционен | как парчетата работят заедно: с база данни, файл, друга услуга | поръчката се записва в базата и се чете обратно |
| End-to-end | цялото приложение, както го ползва човек | клиентът слага в количката, плаща и получава имейл |
Защо си струва:
- грешката се вижда веднага, докато още помниш какво си променил, а не когато клиентът се оплаче;
- тестът показва как се ползва кодът: кой случай какво връща;
- можеш да подреждаш кода смело. Ако тестовете са зелени и след промяната, поведението не се е променило.
По-точно, за любопитните
Има и други видове тестове: функционални (отговаря ли програмата на изискванията), за производителност (колко заявки издържа), за сигурност (има ли пробиви) и на потребителския интерфейс (бутоните, формите). Всички те стъпват на unit тестовете: те са най-много, най-бързи и най-евтини за поддържане.
Виж го
Първият тест
Discount са в отделен клас DiscountTest: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)); }}Стъпка по стъпка, с думи
- DiscountTest има два теста. JUnit пуска всеки поотделно и пали лампичка до него.
- Тест 1, подготовка: цена 50 € и 10% отстъпка.
- Действие: Discount.apply(50, 10) връща 45.0.
- Проверка: assertEquals сравнява очакваното 45.0 с върнатото 45.0. Еднакви са, тестът минава.
- Тест 2: точно 100 € и 10%. Очакваме 85.0, защото от 100 € нагоре има още 5%.
- Условието в метода е price > 100, а 100 не е по-голямо от 100. Методът връща 90.0.
- Очаквано 85.0, върнато 90.0: тестът пада и казва точно това.
- Поправяш условието на price >= 100 и пускаш тестовете пак.
- Сега 100 минава условието и методът връща 85.0.
- Очаквано и върнато съвпадат. И двата теста са зелени.
По-точно, за любопитните
- В истински проект тестовете са в
src/test/java, до кода вsrc/main/java. IntelliJ показва зелена стрелка до всеки тест и до класа: натискаш я и виждаш резултата.
src/main/java/Discount.java кодътsrc/test/java/DiscountTest.java тестовете за него- JUnit се добавя като зависимост. С Maven това е в
pom.xml, а тестовете се пускат сmvn test:
<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)); }}3 по 1,2 в double не е точно 3,6. Дробните числа се пазят в двоичен вид и при сметките се губи малка част. assertEquals за double сравнява точно, затова тестът пада:
ОК coffee()ПАДА tea(): expected: <3.6> but was: <3.5999999999999996>Тестове: 2, минали: 1, паднали: 1assertEquals(3.6, Vat.withVat(3), 0.001) минава, защото разликата е под 0,001.Идеята
Да или не, и очаквано изключение
Освен равенство често проверяваш условие или че кодът отказва. Ето Account от модула за изключенията, с нов метод canPay:
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; }}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, паднали: 0assertTrueиassertFalseпроверяват условие.assertThrows(вид.class, код)пада, ако кодът не хвърли изключение от този вид. Кодът се подава като ламбда:assertThrowsсам го пуска и хваща изключението. После го връща, за да провериш и съобщението.
Вторият тест проверява и нещо, което лесно се забравя: след отказа балансът е същият.
Идеята
Всеки тест започва на чисто
Тук двата теста делят една сметка от 100 €:
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, паднали: 1JUnit е пуснал първо теста за 40 €. На втория са останали 60 €, а той тегли 70 €. Ако редът беше обратният, щеше да падне другият. Тест, който зависи от друг, е капан. Затова обектът се създава наново преди всеки тест с @BeforeEach:
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 ги дава като редове „цена, процент, очаквано“:
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 € нагоре е безплатна. Тестът:
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На кой ред в кода е грешката?
Избери реда с грешката.
Падналият тест казва: при поръчка за точно 50 € доставката е 4 €, а трябва да е 0. Значи 50 не минава условието. total > 50 изключва самото 50. Трябва да е total >= 50. Тестът сочи случая, а кодът показва причината.
Идеята
Първо тестът
Касата трябва да приема промокод ESEN10 за 10%, без значение дали е с малки или главни букви. Този път пишеш тестовете първо:
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")); }}- Червено. Методът засега връща 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- Зелено. Пишеш най-простия код, с който минават:
class Promo { static int percent(String code) { if (code.equalsIgnoreCase("ESEN10")) { return 10; } return 0; }}ОК codeIsCaseInsensitive()ОК knownCode()ОК unknownCode()Тестове: 3, минали: 3, паднали: 0- Подреждане. Сега можеш да изнесеш кодовете в HashMap, да добавиш нови и да подредиш метода. Тестовете ще кажат, ако счупиш нещо.
Това е TDD (test-driven development, разработка, водена от тестовете). Тестът първо те кара да решиш какво точно трябва да прави методът, преди да мислиш как.
По-точно
Сюит и фалшиви обекти
Сюит е група тестови класове, които се пускат заедно, например всички тестове на касата:
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:
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, паднали: 22. Сметката. Напиши AccountTest за Account от картата „Да или не, и очаквано изключение“. Създавай сметката с 100 € в @BeforeEach. Провери, че:
- теглене на 30 € оставя 70 €;
- може да се изтегли всичко и остава 0;
- теглене на 150 € хвърля
InsufficientFundsExceptionсъс съобщение „Не стигат 50 €“, а балансът остава 100 €; - 0 и -20 € хвърлят
IllegalArgumentException(един параметризиран тест с @ValueSource(ints = { 0, -20 })).
3. Бъгът в средната оценка. Методът изглежда наред:
class Grades { static double average(int[] grades) { int sum = 0; for (int g : grades) { sum += g; } return sum / grades.length; }}ОК wholeAverage()ПАДА averageWithAFraction(): expected: <5.5> but was: <5.0>Тестове: 2, минали: 1, паднали: 1Намери причината и поправи метода, така че и двата да минат.
Тест