Тестовете в CI
Накратко: CI пуска тестовете при всяка промяна, на чиста машина, и не позволява сливане, докато нещо е червено. Падналият тест е сигнал: дефект, сменено правило, грешен тест или счупена среда. Най-опасен е тестът, който пада понякога: хората свикват да го пускат пак и пропускат истинското падане. Причината почти винаги е нещо, което тестът не контролира, като часа.
Проблем
Червено през нощта, зелено сутрин
Всяка нощ в 1 ч. българско време сървърът пуска всички тестове. Три нощи поред пада тестът „промо кодът важи в последния си ден“. Сутрин програмистът го пуска пак, и той минава.
„Понякога пада, пусни го пак“ стана навик. След месец никой не гледаше нощния отчет. Тогава падна истински тест за отстъпките, и никой не го видя до оплакването на първия клиент.
Идеята
Непрекъсната интеграция
Непрекъснатата интеграция (continuous integration, CI) е договорка: при всяка промяна, още преди да влезе в общия код, сървър взима кода на чиста машина, компилира го и пуска тестовете. Ако нещо е червено, промяната не се слива.
Ето конфигурацията за GitHub Actions, един от най-разпространените инструменти. Пуска се при всяко качване и при всяка заявка за сливане:
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=uiname: Тестове
on: push: pull_request:
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-dotnet@v6 with: dotnet-version: 10.0.x # Без UI тестовете (Category=UI): те вървят отделно, веднъж на нощ. - run: dotnet test --filter "Category!=UI"name: Тестове
on: push: pull_request:
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-python@v7 with: python-version: "3.12" - run: pip install -r requirements.txt # Без UI тестовете (белязани с ui): те вървят отделно, веднъж на нощ. - run: pytest -m "not ui" --junitxml=report.xml && behavename: Тестове
on: push: pull_request:
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 22 - run: npm ci # UI тестовете (tests/*.spec.js) не са тук: те вървят с Playwright отделно, веднъж на нощ. - run: node --test && npx cucumber-jsТук вървят тестовете на ниво компонент, сценариите и API тестовете от този модул. UI тестовете са отделно, веднъж на нощ срещу тестовата среда: те искат браузър и работещ магазин и са твърде бавни за всяка промяна.
По-точно, за любопитните
Конфигурацията е показана, но не е пусната в курса: за нея трябва GitHub. Другите разпространени инструменти са GitLab CI, Jenkins и Azure Pipelines. Идеята и стъпките са същите: вземи кода, подготви езика, пусни тестовете, запиши отчета.
Правилото „червеното спира сливането“ се настройва отделно, в защитата на основния клон (в GitHub: branch protection, задължителни проверки). Без него CI е само съвет.
Пробвай
Пътят на една промяна
Подреди какво става от качването на промяната до сливането.
CI взима кода на чиста машинаТестовете на ниво компонентПрограмистът качва промяната и отваря заявка за сливанеОтчетът: кое мина и кое паднаСценариите и API тестоветеСливането е позволено само ако всичко е зеленоКодът се компилира
Най-бързите тестове са първи: ако падне тест на ниво компонент, няма смисъл да се чакат по-бавните. Отчетът е преди решението, защото решението се взима по него.
Идеята
Защо пада един тест
Червеното в CI е сигнал, а не присъда. Причините са четири, и всяка иска друго:
- дефект в кода: промяната е счупила нещо. Поправя се кодът. Това е регресията, заради която тестовете съществуват;
- правилото е сменено: тестът казва старото изискване. Тестът се сменя, но по новото изискване, а не „докато мине“;
- грешен тест: тестът проверява нещо, което никой не е искал. Поправя се тестът, и се казва на автора;
- средата: базата не е тръгнала, мрежата е бавна. Търси се в машината, не в продукта.
Едно не е причина: „пусни го пак“. Ако тестът минава от втория път без промяна, проблемът е още там.
Пробвай
Какво значи това червено?
Идеята
Тест, който понякога пада
Ето теста от началото. Промо кодът важи до последния си ден включително. Кодът пита „кой ден е днес“ по часовата зона на магазина, София:
public boolean isValidToday() { LocalDate today = LocalDate.now(ZoneId.of("Europe/Sofia")); return !today.isAfter(lastDay);}public bool IsValidToday(){ var sofia = TimeZoneInfo.FindSystemTimeZoneById("Europe/Sofia"); var today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, sofia)); return today <= lastDay;}def is_valid_today(self): today = datetime.now(ZoneInfo("Europe/Sofia")).date() return today <= self.last_dayisValidToday() { const today = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Sofia" }).format(new Date()); return today <= this.lastDay;}А тестът прави код с последен ден „днес“, по часовника на машината:
@Test@DisplayName("важи в последния си ден")void validOnItsLastDay() { PromoCode code = new PromoCode("ЕСЕН10", LocalDate.now()); assertTrue(code.isValidToday());}[Fact(DisplayName = "важи в последния си ден")]public void ValidOnItsLastDay(){ var code = new PromoCode("ЕСЕН10", DateOnly.FromDateTime(DateTime.Now)); Assert.True(code.IsValidToday());}def test_valid_on_its_last_day(): code = PromoCode("ЕСЕН10", date.today()) assert code.is_valid_today()test("важи в последния си ден", () => { const today = new Date().toISOString().slice(0, 10); const code = new PromoCode("ЕСЕН10", today); assert.equal(code.isValidToday(), true);});На лаптопа в София двете „днес“ са един ден, и тестът минава винаги. Сървърът на CI е с часова зона UTC. Между полунощ и 2 ч. (през лятото до 3 ч.) българско време в София вече е утре, а на сървъра още е днес. Кодът казва: последният ден е минал. Тестът пада точно когато върви нощната сборка. (В JavaScript тестът взима датата винаги по UTC, затова пада в този час и на лаптопа.)
Такъв тест се казва flaky: понякога минава, понякога пада, без промяна в кода. Причините почти винаги са нещо, което тестът не контролира:
- часът и датата, часовата зона;
- редът: тестът зависи от данни, оставени от друг тест;
- чакането: фиксирана пауза, която понякога не стига;
- мрежата и външни услуги;
- случайни данни без фиксирано начало.
По-точно, за любопитните
Кодът и тестът в тази карта са показани и компилирани, но не са пуснати в курса: резултатът им зависи от момента на пускане, и проверка с очакван изход не е възможна. Точно това е проблемът им.
Идеята
Поправката: денят е вход
Тестът не може да контролира часа, затова денят става параметър. Кодът пита „важи ли на този ден“:
// Кодът важи до последния си ден включително.public boolean isValidOn(LocalDate day) { return !day.isAfter(lastDay);}// Кодът важи до последния си ден включително.public bool IsValidOn(DateOnly day) => day <= lastDay;def is_valid_on(self, day): """Кодът важи до последния си ден включително.""" return day <= self.last_day// Кодът важи до последния си ден включително. Дните са текст във вида "2026-11-30".isValidOn(day) { return day <= this.lastDay;}Тестът задава конкретни дати, а не „днес“. И сега може да провери точно границата: последният ден и денят след него:
@Test@DisplayName("важи в последния си ден")void validOnItsLastDay() { assertTrue(code.isValidOn(LocalDate.of(2026, 11, 30)));}[Fact(DisplayName = "важи в последния си ден")]public void ValidOnItsLastDay() => Assert.True(code.IsValidOn(new DateOnly(2026, 11, 30)));def test_valid_on_its_last_day(): assert code.is_valid_on(date(2026, 11, 30))test("важи в последния си ден", () => { assert.equal(code.isValidOn("2026-11-30"), true);});Целият файл, пуснат. Минава при всяко пускане, на всяка машина, във всеки час:
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, паднали: 0using Xunit;
namespace Shop;
public class PromoCodeTest{ private readonly PromoCode code = new("ЕСЕН10", new DateOnly(2026, 11, 30));
[Fact(DisplayName = "важи преди последния си ден")] public void ValidBeforeItsLastDay() => Assert.True(code.IsValidOn(new DateOnly(2026, 11, 1)));
[Fact(DisplayName = "важи в последния си ден")] public void ValidOnItsLastDay() => Assert.True(code.IsValidOn(new DateOnly(2026, 11, 30)));
[Fact(DisplayName = "не важи на следващия ден")] public void NotValidTheDayAfter() => Assert.False(code.IsValidOn(new DateOnly(2026, 12, 1)));}ОК важи в последния си денОК важи преди последния си денОК не важи на следващия денТестове: 3, минали: 3, паднали: 0from datetime import date
from promo_code import PromoCode
code = PromoCode("ЕСЕН10", date(2026, 11, 30))
def test_valid_before_its_last_day(): assert code.is_valid_on(date(2026, 11, 1))
def test_valid_on_its_last_day(): assert code.is_valid_on(date(2026, 11, 30))
def test_not_valid_the_day_after(): assert not code.is_valid_on(date(2026, 12, 1))ОК test_not_valid_the_day_afterОК test_valid_before_its_last_dayОК test_valid_on_its_last_dayТестове: 3, минали: 3, паднали: 0import { test } from "node:test";import assert from "node:assert/strict";import { PromoCode } from "./promo-code.js";
const code = new PromoCode("ЕСЕН10", "2026-11-30");
test("важи преди последния си ден", () => { assert.equal(code.isValidOn("2026-11-01"), true);});
test("важи в последния си ден", () => { assert.equal(code.isValidOn("2026-11-30"), true);});
test("не важи на следващия ден", () => { assert.equal(code.isValidOn("2026-12-01"), false);});ОК важи в последния си денОК важи преди последния си денОК не важи на следващия денТестове: 3, минали: 3, паднали: 0А ако някой напише „преди последния ден“ вместо „до последния ден включително“, тестът пада всеки път, а не понякога:
// Кодът важи до последния си ден включително.public boolean isValidOn(LocalDate day) { return day.isBefore(lastDay);}// Кодът важи до последния си ден включително.public bool IsValidOn(DateOnly day) => day < lastDay;def is_valid_on(self, day): """Кодът важи до последния си ден включително.""" return day < self.last_day// Кодът важи до последния си ден включително. Дните са текст във вида "2026-11-30".isValidOn(day) { return day < this.lastDay;}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, паднали: 1using Xunit;
namespace Shop;
public class PromoCodeTest{ private readonly PromoCode code = new("ЕСЕН10", new DateOnly(2026, 11, 30));
[Fact(DisplayName = "важи преди последния си ден")] public void ValidBeforeItsLastDay() => Assert.True(code.IsValidOn(new DateOnly(2026, 11, 1)));
[Fact(DisplayName = "важи в последния си ден")] public void ValidOnItsLastDay() => Assert.True(code.IsValidOn(new DateOnly(2026, 11, 30)));
[Fact(DisplayName = "не важи на следващия ден")] public void NotValidTheDayAfter() => Assert.False(code.IsValidOn(new DateOnly(2026, 12, 1)));}ОК важи преди последния си денОК не важи на следващия денПАДА важи в последния си ден: Assert.True() Failure Expected: True Actual: FalseТестове: 3, минали: 2, паднали: 1from datetime import date
from promo_code import PromoCode
code = PromoCode("ЕСЕН10", date(2026, 11, 30))
def test_valid_before_its_last_day(): assert code.is_valid_on(date(2026, 11, 1))
def test_valid_on_its_last_day(): assert code.is_valid_on(date(2026, 11, 30))
def test_not_valid_the_day_after(): assert not code.is_valid_on(date(2026, 12, 1))ОК test_not_valid_the_day_afterОК test_valid_before_its_last_dayПАДА test_valid_on_its_last_day: assert FalseТестове: 3, минали: 2, паднали: 1import { test } from "node:test";import assert from "node:assert/strict";import { PromoCode } from "./promo-code.js";
const code = new PromoCode("ЕСЕН10", "2026-11-30");
test("важи преди последния си ден", () => { assert.equal(code.isValidOn("2026-11-01"), true);});
test("важи в последния си ден", () => { assert.equal(code.isValidOn("2026-11-30"), true);});
test("не важи на следващия ден", () => { assert.equal(code.isValidOn("2026-12-01"), false);});ОК важи преди последния си денОК не важи на следващия денПАДА важи в последния си ден: false !== trueТестове: 3, минали: 2, паднали: 1По-точно, за любопитните
В Java и C# има и стандартен начин часовникът да се подаде отвън: java.time.Clock и TimeProvider (от .NET 8). Тогава кодът пита часовника, а тестът му дава фиксиран. В Python и JavaScript се ползват библиотеки, които спират времето в теста (freezegun, фалшивите таймери на тестовите инструменти). Най-простият начин обаче е този от картата: денят е параметър.
Отговорът на въпроса „кой ден е днес за промо кода“ е решение на бизнеса, а не на програмиста. Тук е избрано: по часовата зона на магазина.
Идеята
Отчетът
Отчетът от CI е първото, което някой вижда, когато нещо е червено. Добрият отчет казва, без да питаш никого:
- кой тест е паднал, с име, което казва правилото („важи в последния си ден“), а не
test17; - очаквано и получено: „очаквано: вярно, получено: невярно“;
- при коя промяна: кой е качил какво;
- историята: пада ли за първи път, или и вчера, и онзи ден.
Инструментите пишат отчета във формат, който CI разбира (най-често JUnit XML), и CI го показва до промяната.
Ако тест пада понякога и поправката ще отнеме време, честният ход е той да се отдели от задължителните тестове (карантина), с доклад за дефект и срок, а проверката да се прави ръчно. Не да се изтрие и не да се пуска, докато мине.
Упражнение
Червеното в CI
1. Причини и поправки. Въпросите са за промо кода и CI на магазина.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Защо и какво да автоматизираш · От сценарий към стъпки · Тестове на ниво компонент · API и потребителски интерфейс
- Продължава в: Промо кодове: от заявката до пускането
- Задачи по темата: Автоматизация