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

Тестовете в CI

Накратко: CI пуска тестовете при всяка промяна, на чиста машина, и не позволява сливане, докато нещо е червено. Падналият тест е сигнал: дефект, сменено правило, грешен тест или счупена среда. Най-опасен е тестът, който пада понякога: хората свикват да го пускат пак и пропускат истинското падане. Причината почти винаги е нещо, което тестът не контролира, като часа.

Проблем

Червено през нощта, зелено сутрин

Всяка нощ в 1 ч. българско време сървърът пуска всички тестове. Три нощи поред пада тестът „промо кодът важи в последния си ден“. Сутрин програмистът го пуска пак, и той минава.

„Понякога пада, пусни го пак“ стана навик. След месец никой не гледаше нощния отчет. Тогава падна истински тест за отстъпките, и никой не го видя до оплакването на първия клиент.

Идеята

Непрекъсната интеграция

Непрекъснатата интеграция (continuous integration, CI) е договорка: при всяка промяна, още преди да влезе в общия код, сървър взима кода на чиста машина, компилира го и пуска тестовете. Ако нещо е червено, промяната не се слива.

Ето конфигурацията за GitHub Actions, един от най-разпространените инструменти. Пуска се при всяко качване и при всяка заявка за сливане:

.github/workflows/tests.yml
name: Тестове
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 21
# Без UI тестовете (белязани с ui): те вървят отделно, веднъж на нощ.
- run: mvn --batch-mode test -DexcludedGroups=ui

Тук вървят тестовете на ниво компонент, сценариите и API тестовете от този модул. UI тестовете са отделно, веднъж на нощ срещу тестовата среда: те искат браузър и работещ магазин и са твърде бавни за всяка промяна.

По-точно, за любопитните

Конфигурацията е показана, но не е пусната в курса: за нея трябва GitHub. Другите разпространени инструменти са GitLab CI, Jenkins и Azure Pipelines. Идеята и стъпките са същите: вземи кода, подготви езика, пусни тестовете, запиши отчета.

Правилото „червеното спира сливането“ се настройва отделно, в защитата на основния клон (в GitHub: branch protection, задължителни проверки). Без него CI е само съвет.

Пробвай

Пътят на една промяна

Подреди какво става от качването на промяната до сливането.

  1. CI взима кода на чиста машина
  2. Тестовете на ниво компонент
  3. Програмистът качва промяната и отваря заявка за сливане
  4. Отчетът: кое мина и кое падна
  5. Сценариите и API тестовете
  6. Сливането е позволено само ако всичко е зелено
  7. Кодът се компилира

Идеята

Защо пада един тест

Червеното в CI е сигнал, а не присъда. Причините са четири, и всяка иска друго:

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

Едно не е причина: „пусни го пак“. Ако тестът минава от втория път без промяна, проблемът е още там.

Пробвай

Какво значи това червено?

1. Тестът за безплатна доставка пада след промяна в кода за промо кодовете. Правилото за доставката не е сменяно.
2. Собственикът реши прагът да е 60 €. Програмистът смени кода, и тестът за 50,00 € пада.
3. Всички API тестове падат с „connection refused“, а вчера минаваха. Кодът не е пипан.
4. Тест пада веднъж на десет пускания, без промяна в кода.

Идеята

Тест, който понякога пада

Ето теста от началото. Промо кодът важи до последния си ден включително. Кодът пита „кой ден е днес“ по часовата зона на магазина, София:

src/main/java/shop/PromoCode.java
public boolean isValidToday() {
LocalDate today = LocalDate.now(ZoneId.of("Europe/Sofia"));
return !today.isAfter(lastDay);
}

А тестът прави код с последен ден „днес“, по часовника на машината:

src/test/java/shop/PromoCodeTest.java
@Test
@DisplayName("важи в последния си ден")
void validOnItsLastDay() {
PromoCode code = new PromoCode("ЕСЕН10", LocalDate.now());
assertTrue(code.isValidToday());
}

На лаптопа в София двете „днес“ са един ден, и тестът минава винаги. Сървърът на CI е с часова зона UTC. Между полунощ и 2 ч. (през лятото до 3 ч.) българско време в София вече е утре, а на сървъра още е днес. Кодът казва: последният ден е минал. Тестът пада точно когато върви нощната сборка. (В JavaScript тестът взима датата винаги по UTC, затова пада в този час и на лаптопа.)

Такъв тест се казва flaky: понякога минава, понякога пада, без промяна в кода. Причините почти винаги са нещо, което тестът не контролира:

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

Кодът и тестът в тази карта са показани и компилирани, но не са пуснати в курса: резултатът им зависи от момента на пускане, и проверка с очакван изход не е възможна. Точно това е проблемът им.

Идеята

Поправката: денят е вход

Тестът не може да контролира часа, затова денят става параметър. Кодът пита „важи ли на този ден“:

src/main/java/shop/PromoCode.java
// Кодът важи до последния си ден включително.
public boolean isValidOn(LocalDate day) {
return !day.isAfter(lastDay);
}

Тестът задава конкретни дати, а не „днес“. И сега може да провери точно границата: последният ден и денят след него:

src/test/java/shop/PromoCodeTest.java
@Test
@DisplayName("важи в последния си ден")
void validOnItsLastDay() {
assertTrue(code.isValidOn(LocalDate.of(2026, 11, 30)));
}

Целият файл, пуснат. Минава при всяко пускане, на всяка машина, във всеки час:

src/test/java/shop/PromoCodeTest.java
package shop;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PromoCodeTest {
private final PromoCode code = new PromoCode("ЕСЕН10", LocalDate.of(2026, 11, 30));
@Test
@DisplayName("важи преди последния си ден")
void validBeforeItsLastDay() {
assertTrue(code.isValidOn(LocalDate.of(2026, 11, 1)));
}
@Test
@DisplayName("важи в последния си ден")
void validOnItsLastDay() {
assertTrue(code.isValidOn(LocalDate.of(2026, 11, 30)));
}
@Test
@DisplayName("не важи на следващия ден")
void notValidTheDayAfter() {
assertFalse(code.isValidOn(LocalDate.of(2026, 12, 1)));
}
}
Изход
ОК важи в последния си ден
ОК важи преди последния си ден
ОК не важи на следващия ден
Тестове: 3, минали: 3, паднали: 0

А ако някой напише „преди последния ден“ вместо „до последния ден включително“, тестът пада всеки път, а не понякога:

src/main/java/shop/PromoCode.java
// Кодът важи до последния си ден включително.
public boolean isValidOn(LocalDate day) {
return day.isBefore(lastDay);
}
src/test/java/shop/PromoCodeTest.java
package shop;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PromoCodeTest {
private final PromoCode code = new PromoCode("ЕСЕН10", LocalDate.of(2026, 11, 30));
@Test
@DisplayName("важи преди последния си ден")
void validBeforeItsLastDay() {
assertTrue(code.isValidOn(LocalDate.of(2026, 11, 1)));
}
@Test
@DisplayName("важи в последния си ден")
void validOnItsLastDay() {
assertTrue(code.isValidOn(LocalDate.of(2026, 11, 30)));
}
@Test
@DisplayName("не важи на следващия ден")
void notValidTheDayAfter() {
assertFalse(code.isValidOn(LocalDate.of(2026, 12, 1)));
}
}
Изход
ОК важи преди последния си ден
ОК не важи на следващия ден
ПАДА важи в последния си ден: expected: <true> but was: <false>
Тестове: 3, минали: 2, паднали: 1
По-точно, за любопитните

В Java и C# има и стандартен начин часовникът да се подаде отвън: java.time.Clock и TimeProvider (от .NET 8). Тогава кодът пита часовника, а тестът му дава фиксиран. В Python и JavaScript се ползват библиотеки, които спират времето в теста (freezegun, фалшивите таймери на тестовите инструменти). Най-простият начин обаче е този от картата: денят е параметър.

Отговорът на въпроса „кой ден е днес за промо кода“ е решение на бизнеса, а не на програмиста. Тук е избрано: по часовата зона на магазина.

Идеята

Отчетът

Отчетът от CI е първото, което някой вижда, когато нещо е червено. Добрият отчет казва, без да питаш никого:

  • кой тест е паднал, с име, което казва правилото („важи в последния си ден“), а не test17;
  • очаквано и получено: „очаквано: вярно, получено: невярно“;
  • при коя промяна: кой е качил какво;
  • историята: пада ли за първи път, или и вчера, и онзи ден.

Инструментите пишат отчета във формат, който CI разбира (най-често JUnit XML), и CI го показва до промяната.

Ако тест пада понякога и поправката ще отнеме време, честният ход е той да се отдели от задължителните тестове (карантина), с доклад за дефект и срок, а проверката да се прави ръчно. Не да се изтрие и не да се пуска, докато мине.

Упражнение

Червеното в CI

1. Причини и поправки. Въпросите са за промо кода и CI на магазина.

1. Тестът „важи в последния си ден“ пада само в нощната сборка. Кой въпрос задаваш първо?
2. Как се поправя тест, който зависи от днешната дата?
3. Тест понякога пада, а поправката ще отнеме дни. Какво е честно да се направи междувременно?
4. Тест А минава сам, но пада, когато тест Б е пуснат преди него. Каква е причината най-вероятно?

Тест

Провери се

1. Какво е непрекъсната интеграция (CI)?
2. Защо CI пуска тестовете на чиста машина, а не на лаптопа на програмиста?
3. Защо тестът, който понякога пада, е опасен?
4. Какво трябва да казва отчетът за паднал тест?

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