diff --git a/.vitepress/config.mts b/.vitepress/config.mts index 0c88432..a2e4563 100644 --- a/.vitepress/config.mts +++ b/.vitepress/config.mts @@ -112,6 +112,7 @@ gtag('config', 'G-VYGDN3X0PR');`], { text: 'Data Providers', link: '/docs/plugins/data.md' }, { text: 'Retry', link: '/docs/plugins/retry.md' }, { text: 'Repeat', link: '/docs/plugins/repeat.md' }, + { text: 'Skip', link: '/docs/plugins/skip.md' }, { text: 'Fiber', link: '/docs/plugins/fiber.md' }, { text: 'Bench', link: '/docs/plugins/bench.md' }, { text: '\#[Test]', link: '/docs/plugins/test.md' }, @@ -193,6 +194,7 @@ gtag('config', 'G-VYGDN3X0PR');`], { text: 'Inline (встроенные тесты)', link: '/ru/docs/plugins/inline.md' }, { text: 'Retry', link: '/ru/docs/plugins/retry.md' }, { text: 'Repeat', link: '/ru/docs/plugins/repeat.md' }, + { text: 'Skip (пропуск тестов)', link: '/ru/docs/plugins/skip.md' }, { text: 'Fiber (файберы)', link: '/ru/docs/plugins/fiber.md' }, { text: 'Bench', link: '/ru/docs/plugins/bench.md' }, { text: '\#[Test]', link: '/ru/docs/plugins/test.md' }, diff --git a/docs/intro/writing-tests.md b/docs/intro/writing-tests.md index 1551570..6f9f2d0 100644 --- a/docs/intro/writing-tests.md +++ b/docs/intro/writing-tests.md @@ -134,6 +134,13 @@ Instead of base classes or magic methods, Testo bets on attributes. public function flakyExternalService(): void { /* ... */ } ``` +- The \Testo\Skip attribute from the Skip plugin skips a test without deleting it: the test doesn't run, but stays in the report as \Testo\Core\Value\Status::Skipped with its reason: + + ```php + #[Skip('broken by the pricing rework')] + public function calculatesTotal(): void { /* ... */ } + ``` + - Lifecycle hooks from the Lifecycle plugin help set up the environment and clean state between tests: - \Testo\Lifecycle\BeforeTest — runs before each test. - \Testo\Lifecycle\AfterTest — runs after each test. diff --git a/docs/plugins/lifecycle.md b/docs/plugins/lifecycle.md index ee35ec0..8796fc8 100644 --- a/docs/plugins/lifecycle.md +++ b/docs/plugins/lifecycle.md @@ -83,6 +83,10 @@ BeforeClass (once) AfterClass (once) ``` +::: info +For a test marked with \Testo\Skip, the \Testo\Lifecycle\BeforeTest and \Testo\Lifecycle\AfterTest hooks are not called: the test is reported as skipped before its run begins. See the Skip plugin for details. +::: + ## Basic Example ```php diff --git a/docs/plugins/skip.md b/docs/plugins/skip.md new file mode 100644 index 0000000..e5243e3 --- /dev/null +++ b/docs/plugins/skip.md @@ -0,0 +1,146 @@ +--- +outline: [2, 3] +llms_description: "How to skip a test declaratively with #[Skip]: the test is reported as Skipped with its reason before anything runs, so #[BeforeTest]/#[AfterTest], data providers, #[Retry]/#[Repeat] and coverage never engage, and a fully skipped class runs no #[BeforeClass]/#[AfterClass]. Class-level skip and inheritance, where the reason shows up in reports, the SkipTest exception for skipping at run time, and when to use #[Skip], SkipTest or a #[Group] filter." +--- + +# Skip + +The plugin provides the \Testo\Skip attribute and an interceptor that mark a test as skipped before it ever starts. The test is reported as \Testo\Core\Value\Status::Skipped and counted in the totals, and an optional reason explains why it was skipped. Skip a test when it cannot run yet but it is too early to delete it: it reproduces a bug nobody has fixed yet, it is broken by a rework still in progress, or it was written ahead of the feature it checks. The attribute can be placed on a method, function, or an entire class — in the latter case, every test in the class is skipped. + + + + +Marks a test, a test class or a test function as skipped without running it. + +Can be placed on a method, a free function, or a class — on a class every test of the case is skipped. The attribute is inherited from parent classes, traits and overridden methods. When both a method and its class carry `#[Skip]`, the method's attribute takes precedence and its reason replaces the class one. The attribute can be placed only once per target. + +The attribute applies to plain tests only: on a non-test method it does nothing, and a \Testo\Bench or \Testo\Inline\TestInline target runs as usual. Close in spirit to JUnit's `@Disabled` and Rust's `#[ignore]`. + +Why the test is skipped. No reason by default. A given reason is appended to the result message and shows up in the JUnit, TeamCity and HTML reports. + +Skip a single test: + +```php +use Testo\Skip; +use Testo\Test; + +final class PricingTest +{ + #[Test] + #[Skip('broken by the pricing rework')] + public function calculatesTotal(): void + { + // never runs — reported as Skipped with the reason above + } + + #[Test] + public function createsOrder(): void { /* runs as usual */ } +} +``` + + +On a class — every test of the case is skipped, and a method may state its own reason: + +```php +#[Skip('the billing sandbox is down')] +final class BillingTest +{ + #[Test] + public function chargesCard(): void { /* ... */ } + + #[Test] + #[Skip('flaky since the gateway upgrade')] // this reason replaces the class one + public function refundsCard(): void { /* ... */ } +} +``` + + + +## What never runs + +The skip is decided before the test starts: the test is reported as \Testo\Core\Value\Status::Skipped on the spot, and that's the end of it. Nothing that normally prepares, wraps or repeats the test body ever runs: + +- \Testo\Lifecycle\BeforeTest and \Testo\Lifecycle\AfterTest hooks are not called. +- Data providers such as \Testo\Data\DataProvider are not called: a data-driven test yields a **single** \Testo\Core\Value\Status::Skipped entry, not one per data set. +- \Testo\Retry and \Testo\Repeat never start their loop. +- \Testo\Fiber\RunInFiber doesn't start a fiber. +- No code coverage is collected. + +```php +final class OrderTest +{ + #[BeforeTest] + public function startTransaction(): void + { + // not called for calculatesTotal() — there is no body to prepare for + } + + #[Test] + #[Skip('broken by the pricing rework')] + public function calculatesTotal(): void { /* ... */ } + + #[Test] + public function createsOrder(): void + { + // startTransaction() runs for this one as usual + } +} +``` + +Class-level hooks work differently, because they belong to the case rather than to a single test: \Testo\Lifecycle\BeforeClass and \Testo\Lifecycle\AfterClass still run as long as the case has at least one test that isn't skipped. When every test of the case is skipped, they are not called and the class is never even instantiated. + +A run consisting only of skipped tests is a success: \Testo\Core\Value\Status::Skipped is neither a failure nor an error. + +## Where the reason shows up + +The test's result carries a message built from its qualified name — `Class::method`, or the fully qualified function name for a function test — and the marker `is skipped via #[Skip]`, extended with the reason when one is given: + +``` +Tests\Unit\PricingTest::calculatesTotal is skipped via #[Skip] ==> broken by the pricing rework +``` + +- The JUnit ([`--log-junit`](../guide/cli-reference.md#log-junit)), TeamCity ([`--teamcity`](../guide/cli-reference.md#teamcity)) and HTML reports show that message. +- The terminal prints the skipped line without it. +- The compact [`--json`](../guide/cli-reference.md#json) report counts the test in its totals. + +## Skipping at runtime + +Sometimes the skip cannot be decided ahead of time: the test has to look around first and skip itself on what it finds — a missing extension, an unreachable service, a fixture that turned out empty. For that, throw \Testo\Core\Exception\SkipTest from the test body. The test is reported as \Testo\Core\Value\Status::Skipped with the exception message. + +```php +use Testo\Core\Exception\SkipTest; + +#[Test] +public function requiresPdoMysql(): void +{ + if (!\extension_loaded('pdo_mysql')) { + throw new SkipTest('pdo_mysql required'); + } + + // ... +} +``` + +The two mechanisms reach the same status by different roads, and that is the point to keep in mind. The exception is thrown once the test is already running: \Testo\Lifecycle\BeforeTest has done its work, the arguments are ready (from a data provider, if the test has one), and the test class has been instantiated if the method needs an instance. \Testo\Skip is declared ahead of time and never reaches any of that. In reports the two are easy to tell apart: a declared skip carries the `is skipped via #[Skip]` marker in its message. + +::: warning +Throw \Testo\Core\Exception\SkipTest from the test body only. Thrown from an interceptor it leaves the pipeline and the test lands as \Testo\Core\Value\Status::Aborted, not \Testo\Core\Value\Status::Skipped. +::: + +## Skip, SkipTest or a group filter + +All three keep a test from running, but they differ in when the decision is made and whether the test stays in the report: + +- Use \Testo\Skip when **the test must not run for now**, and that decision should be visible both in the code and in the report. +- Throw \Testo\Core\Exception\SkipTest when **only the test itself can decide**, based on what it finds at run time. +- Use \Testo\Filter\Group with `--group=!slow` when **the test is fine**, it just doesn't need to run every time — for example, because it is slow. + +| Tool | Decided | In the report | +|------|---------|---------------| +| `#[Skip('…')]` | in code, ahead of the run | \Testo\Core\Value\Status::Skipped, with the reason | +| `throw new SkipTest('…')` | inside the test, while it runs | \Testo\Core\Value\Status::Skipped, with the message | +| \Testo\Filter\Group + `--group=!slow` | at the runner invocation | not at all | + +::: question Do I need to register the plugin? +No. `SkipPlugin` is part of the default suite plugins, and the attribute wires its own interceptor. In a suite configured without the plugin the test is still reported as \Testo\Core\Value\Status::Skipped, and its \Testo\Lifecycle\BeforeTest/\Testo\Lifecycle\AfterTest hooks are still not called. What is lost is the class-level decision: a class whose tests are all skipped then runs its \Testo\Lifecycle\BeforeClass/\Testo\Lifecycle\AfterClass hooks, and a non-static hook constructs the class. +::: diff --git a/ru/docs/intro/writing-tests.md b/ru/docs/intro/writing-tests.md index 9c0b9af..5e6a205 100644 --- a/ru/docs/intro/writing-tests.md +++ b/ru/docs/intro/writing-tests.md @@ -133,6 +133,13 @@ Expect::notLeaks($connection); public function flakyExternalService(): void { /* ... */ } ``` +- Атрибут \Testo\Skip из плагина Skip пропускает тест, не удаляя его: тест не выполняется, но остаётся в отчёте со статусом \Testo\Core\Value\Status::Skipped и указанной причиной: + + ```php + #[Skip('broken by the pricing rework')] + public function calculatesTotal(): void { /* ... */ } + ``` + - Хуки жизненного цикла из плагина Lifecycle помогут подготовить окружение и очистить состояние между тестами: - \Testo\Lifecycle\BeforeTest — выполняется перед каждым тестом. - \Testo\Lifecycle\AfterTest — выполняется после каждого теста. diff --git a/ru/docs/plugins/lifecycle.md b/ru/docs/plugins/lifecycle.md index 6ee2291..4df702a 100644 --- a/ru/docs/plugins/lifecycle.md +++ b/ru/docs/plugins/lifecycle.md @@ -83,6 +83,10 @@ BeforeClass (один раз) AfterClass (один раз) ``` +::: info +Для теста с атрибутом \Testo\Skip хуки \Testo\Lifecycle\BeforeTest и \Testo\Lifecycle\AfterTest не вызываются: тест помечается пропущенным ещё до начала запуска. Подробнее — в описании плагина Skip. +::: + ## Базовый пример ```php diff --git a/ru/docs/plugins/skip.md b/ru/docs/plugins/skip.md new file mode 100644 index 0000000..0750988 --- /dev/null +++ b/ru/docs/plugins/skip.md @@ -0,0 +1,145 @@ +--- +outline: [2, 3] +--- + +# Пропуск тестов + +Плагин предоставляет атрибут \Testo\Skip и интерцептор, которые помечают тест пропущенным ещё до его запуска. Тест попадает в отчёт со статусом \Testo\Core\Value\Status::Skipped и учитывается в итогах, а необязательная причина объясняет, почему он пропущен. Пропускайте тест, когда запускать его пока нельзя, а удалять рано: он воспроизводит ещё не починенный баг, сломан незавершённым рефакторингом или написан раньше фичи, которую проверяет. Атрибут можно повесить на метод, функцию или целый класс — в последнем случае пропускаются все тесты в классе. + + + + +Помечает тест, класс тестов или тестовую функцию как пропущенные, не запуская их. + +Можно повесить на метод, свободную функцию или класс — на классе пропускаются все тесты тест-кейса. Атрибут наследуется от родительских классов, трейтов и переопределённых методов. Если `#[Skip]` стоит и на методе, и на классе, действует атрибут метода, и его причина заменяет причину класса. Повесить атрибут на один элемент дважды нельзя. + +Атрибут действует только на обычные тесты: на не-тестовом методе он ничего не делает, а \Testo\Bench и \Testo\Inline\TestInline выполняются как обычно. По смыслу близок к `@Disabled` в JUnit и `#[ignore]` в Rust. + +Почему тест пропущен. По умолчанию причины нет. Заданная причина дописывается в сообщение результата и видна в отчётах JUnit, TeamCity и HTML. + +Пропустить один тест: + +```php +use Testo\Skip; +use Testo\Test; + +final class PricingTest +{ + #[Test] + #[Skip('broken by the pricing rework')] + public function calculatesTotal(): void + { + // никогда не выполняется — в отчёте Skipped с причиной выше + } + + #[Test] + public function createsOrder(): void { /* выполняется как обычно */ } +} +``` + + +На классе — пропускаются все тесты тест-кейса, а метод может указать свою причину: + +```php +#[Skip('the billing sandbox is down')] +final class BillingTest +{ + #[Test] + public function chargesCard(): void { /* ... */ } + + #[Test] + #[Skip('flaky since the gateway upgrade')] // эта причина заменяет причину класса + public function refundsCard(): void { /* ... */ } +} +``` + + + +## Что не запускается + +Решение о пропуске принимается ещё до старта теста: тест сразу получает статус \Testo\Core\Value\Status::Skipped, и на этом всё. Поэтому ничего из того, что обычно готовит, оборачивает или повторяет тело теста, не срабатывает: + +- Хуки \Testo\Lifecycle\BeforeTest и \Testo\Lifecycle\AfterTest не вызываются. +- Провайдеры данных, например \Testo\Data\DataProvider, тоже не вызываются. Параметризованный тест даёт в отчёте **одну** запись \Testo\Core\Value\Status::Skipped, а не по записи на каждый набор данных. +- \Testo\Retry и \Testo\Repeat не запускают повторы. +- \Testo\Fiber\RunInFiber не создаёт файбер. +- Покрытие кода не собирается. + +```php +final class OrderTest +{ + #[BeforeTest] + public function startTransaction(): void + { + // для calculatesTotal() не вызывается — готовить нечего + } + + #[Test] + #[Skip('broken by the pricing rework')] + public function calculatesTotal(): void { /* ... */ } + + #[Test] + public function createsOrder(): void + { + // для этого теста startTransaction() выполняется как обычно + } +} +``` + +С хуками уровня класса всё иначе: они привязаны к тест-кейсу, а не к отдельному тесту. \Testo\Lifecycle\BeforeClass и \Testo\Lifecycle\AfterClass выполняются, если в тест-кейсе остался хотя бы один непропущенный тест. Если же пропущены все тесты, эти хуки не вызываются, а экземпляр класса даже не создаётся. + +Прогон, в котором пропущены все тесты, считается успешным: \Testo\Core\Value\Status::Skipped не относится ни к падениям, ни к ошибкам. + +## Причина в отчётах + +В результат теста попадает сообщение из полного имени теста — `Класс::метод` или полное имя функции для функционального теста — и маркера `is skipped via #[Skip]`; если указана причина, она дописывается в конец: + +``` +Tests\Unit\PricingTest::calculatesTotal is skipped via #[Skip] ==> broken by the pricing rework +``` + +- Отчёты JUnit ([`--log-junit`](../guide/cli-reference.md#log-junit)), TeamCity ([`--teamcity`](../guide/cli-reference.md#teamcity)) и HTML показывают это сообщение. +- Терминал печатает строку пропущенного теста без него. +- Компактный отчёт [`--json`](../guide/cli-reference.md#json) учитывает тест в итогах. + +## Пропуск во время выполнения + +Иногда решение о пропуске нельзя принять заранее: тест должен сначала осмотреться и пропустить себя по тому, что обнаружит — нет расширения, недоступен сервис, фикстура оказалась пустой. Для этого бросьте \Testo\Core\Exception\SkipTest из тела теста. Тест попадёт в отчёт со статусом \Testo\Core\Value\Status::Skipped и сообщением исключения. + +```php +use Testo\Core\Exception\SkipTest; + +#[Test] +public function requiresPdoMysql(): void +{ + if (!\extension_loaded('pdo_mysql')) { + throw new SkipTest('pdo_mysql required'); + } + + // ... +} +``` + +Оба механизма приводят к одному статусу, но разными путями, и это стоит помнить. Исключение бросается, когда тест уже выполняется: \Testo\Lifecycle\BeforeTest отработал, аргументы подготовлены (провайдером данных, если он есть), а класс создан, если методу нужен экземпляр. \Testo\Skip объявляется заранее и до всего этого просто не доходит. В отчётах их легко различить: у объявленного пропуска в сообщении есть маркер `is skipped via #[Skip]`. + +::: warning +Бросайте \Testo\Core\Exception\SkipTest только из тела теста. Если бросить его из интерцептора, оно покинет пайплайн, и тест получит статус \Testo\Core\Value\Status::Aborted, а не \Testo\Core\Value\Status::Skipped. +::: + +## Skip, SkipTest или фильтр по группе + +Все три не дают тесту выполниться, но различаются тем, когда принимается решение и остаётся ли тест в отчёте: + +- Берите \Testo\Skip, когда **тест пока не должен запускаться** и это решение должно быть видно и в коде, и в отчёте. +- Бросайте \Testo\Core\Exception\SkipTest, когда **решить может только сам тест** — по тому, что он обнаружит во время выполнения. +- Берите \Testo\Filter\Group с ключом `--group=!slow`, когда **с тестом всё в порядке**, а запускать его в каждом прогоне незачем — например, он слишком долгий. + +| Инструмент | Где принимается решение | В отчёте | +|------------|-------------------------|----------| +| `#[Skip('…')]` | до запуска теста | \Testo\Core\Value\Status::Skipped, с причиной | +| `throw new SkipTest('…')` | внутри теста, во время выполнения | \Testo\Core\Value\Status::Skipped, с сообщением | +| \Testo\Filter\Group + `--group=!slow` | при запуске раннера | не отображается | + +::: question Нужно ли регистрировать плагин? +Нет. `SkipPlugin` входит в набор плагинов по умолчанию, а атрибут сам подключает свой интерцептор. В Test Suite, настроенном без плагина, тест всё равно попадёт в отчёт со статусом \Testo\Core\Value\Status::Skipped, и \Testo\Lifecycle\BeforeTest/\Testo\Lifecycle\AfterTest для него по-прежнему не вызовутся. Теряется только решение на уровне класса: у класса, все тесты которого пропущены, выполнятся \Testo\Lifecycle\BeforeClass/\Testo\Lifecycle\AfterClass, а ради нестатического хука будет создан экземпляр класса. +:::