Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/.release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@
"include-component-in-tag": true,
"changelog-path": "CHANGELOG.md"
},
"plugin/spec": {
"package-name": "testo/spec",
"component": "spec",
"include-component-in-tag": true,
"changelog-path": "CHANGELOG.md"
},
"plugin/filter": {
"package-name": "testo/filter",
"component": "filter",
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/split-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ on: # yamllint disable-line rule:truthy
- 'lifecycle-[0-9]*'
- 'repeat-[0-9]*'
- 'retry-[0-9]*'
- 'spec-[0-9]*'
- 'test-[0-9]*'

name: 📦 Split publish
Expand Down
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"spiral/code-style": "^2.2.2",
"testo/bridge-infection": "^0.1.6",
"testo/facade": "^0.1.1",
"testo/spec": "^0.1.0",
"vimeo/psalm": "^7.0@dev"
},
"suggest": {
Expand Down Expand Up @@ -91,6 +92,7 @@
"Tests\\Lifecycle\\": "plugin/lifecycle/tests/",
"Tests\\Repeat\\": "plugin/repeat/tests/",
"Tests\\Retry\\": "plugin/retry/tests/",
"Tests\\Spec\\": "plugin/spec/tests/",
"Tests\\Test\\": "plugin/test/tests/"
},
"files": [
Expand All @@ -115,6 +117,7 @@
"testo/lifecycle": "0.1.x-dev",
"testo/repeat": "0.1.x-dev",
"testo/retry": "0.1.x-dev",
"testo/spec": "0.1.x-dev",
"testo/test": "0.1.x-dev"
}
}
Expand Down
1 change: 1 addition & 0 deletions plugin/spec/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Changelog
145 changes: 145 additions & 0 deletions plugin/spec/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<p align="center">
<a href="https://github.com/php-testo/testo"><img alt="TESTO"
src="https://github.com/php-testo/.github/blob/1.x/resources/logo-full.svg?raw=true"
style="width: 2in; display: block"
/></a>
</p>

<p align="center">Spec-driven plugin</p>

<div align="center">

[![Documentation](https://img.shields.io/badge/Documentation-blue?style=for-the-badge&logo=gitbook&logoColor=white)](https://php-testo.github.io)
[![Support on Boosty](https://img.shields.io/static/v1?style=for-the-badge&label=&message=Sponsorship&logo=Boosty&logoColor=white&color=%23F15F2C)](https://boosty.to/roxblnfk)

</div>

<br />

> [!IMPORTANT]
> ## 🪞 This is a read-only mirror.
>
> Active development of the Testo project lives in [**php-testo/testo**](https://github.com/php-testo/testo) under `plugin/spec/`. This repository is **automatically synchronized** from there on every release.
>
> File issues and pull requests in the [main monorepo](https://github.com/php-testo/testo/issues), not here.

## About

Blends **BDD**, **Spec-Driven** and **TDD** workflows: write the behaviour you expect as a `#[Spec(...)]`
fragment — a user story or a slice of the product specification — right next to the test that proves it.

At runtime every fragment is published to the `spec.md` messenger channel, so it travels with the test
output. When you want living documentation, flip on generation (the `--spec` flag or the plugin's
`collect` option) and Testo renders the collected fragments into Markdown files, one per Test Case.

## Install

```bash
composer require --dev testo/spec
```

[![PHP](https://img.shields.io/packagist/php-v/testo/spec.svg?style=flat-square&logo=php)](https://packagist.org/packages/testo/spec)
[![Latest Version on Packagist](https://img.shields.io/packagist/v/testo/spec.svg?style=flat-square&logo=packagist)](https://packagist.org/packages/testo/spec)
[![License](https://img.shields.io/packagist/l/testo/spec.svg?style=flat-square)](https://github.com/php-testo/testo/blob/1.x/LICENSE.md)
[![Total Downloads](https://img.shields.io/packagist/dt/testo/spec.svg?style=flat-square)](https://packagist.org/packages/testo/spec/stats)

## Usage

Attach a spec fragment to a test (method, function, or a whole class). `#[Spec]` carries the
*content* (the story), `#[SpecHeader]` carries the *heading and number*:

```php
use Testo\Spec;
use Testo\Spec\SpecHeader;
use Testo\Test;

#[Test]
#[SpecHeader('5', 'Checkout')] // class = numbered section
final class CheckoutTest
{
#[Spec(
story: <<<'MD'
**As a** customer
**I want** my cart total to include tax
**so that** the price I pay matches the price I see.
MD,
tags: ['checkout', 'JIRA-128'],
)]
public function totalIncludesTax(): void // -> item 5.1
{
// ...assertions that prove the story...
}

#[Test]
#[Spec(story: 'A valid coupon lowers the total.')]
#[SpecHeader(title: 'Coupon applies')] // override the item title, still auto-numbered -> 5.2
public function couponApplies(): void
{
// ...
}
}
```

### Numbering & ordering

- `#[SpecHeader]` on a **class** opens a section: `number` is the section number (maps onto your
external spec document), `title` is the heading. Either may be omitted — a section with no number
falls to the end, a section with no title falls back to the class name.
- `#[SpecHeader]` on a **method** overrides that item's title and/or pins its number.
- Items are auto-numbered `{section}.{n}` in source order; a pinned method number is kept as-is.
- **Collisions** (e.g. two cases sharing a section number) are disambiguated with a ` (1)`, ` (2)` …
suffix in document order — numbers are never silently dropped.

The example above renders to:

```markdown
# 5. Checkout

## 5.1 totalIncludesTax

**As a** customer
**I want** my cart total to include tax
**so that** the price I pay matches the price I see.

_Tags: `checkout` `JIRA-128`_

## 5.2 Coupon applies

A valid coupon lowers the total.
```

Sections without a number are gathered into a trailing **Uncategorized** block — items with a header
render as a bullet list, items without one as plain paragraphs.

### Execution order follows the numbers

By default the plugin also **reorders test execution** to match the document: Test Cases run in
section-number order, and tests within a case run in item-number order (unnumbered ones keep source
order and run last). Turn it off with `new SpecPlugin(reorder: false)` if your tests must keep their
discovered order.

### Generate documentation

Register the plugin in `testo.php`:

```php
use Testo\Spec\SpecPlugin;

// In ApplicationConfig::$plugins or a SuiteConfig::$plugins:
new SpecPlugin(outputDir: __DIR__ . '/docs/specs'),
```

Reordering is on as soon as the plugin is registered. File generation is separate — enable it with
`collect: true` on the plugin, or from the CLI:

```bash
# Generate into the plugin's configured directory
vendor/bin/testo --spec

# Generate into a custom directory
vendor/bin/testo --spec-dir=docs/specs
```

The whole run is rendered into a single ordered `spec.md` in the target directory. Even without the
plugin the fragments are still emitted to the `spec.md` channel — generation and reordering are the
optional halves.
51 changes: 51 additions & 0 deletions plugin/spec/Spec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace Testo;

use Testo\Pipeline\Attribute\FallbackInterceptor;
use Testo\Pipeline\Attribute\Interceptable;
use Testo\Spec\Internal\SpecInterceptor;

/**
* Attach a specification fragment to a test — the *content* of a spec item.
*
* Combines BDD, Spec-Driven and TDD workflows: the spec — a user story or a slice of the product
* specification — lives right next to the test that proves it. At runtime the fragment is published
* to the {@see SpecInterceptor::CHANNEL} messenger channel, and the {@see \Testo\Spec\SpecPlugin}
* can render the collected fragments into Markdown files on demand (`--spec` CLI flag).
*
* The attribute can sit on a method/function (one test) or on a class (every test in the case
* inherits it), mirroring {@see Retry} and {@see Repeat}.
*
* Headings and numbering live in the companion {@see \Testo\Spec\SpecHeader}: a class-level `SpecHeader` opens a
* numbered section, and each `Spec` becomes an auto-numbered item under it (`5.1`, `5.2`, …).
*
* @api
*/
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION | \Attribute::TARGET_CLASS)]
#[FallbackInterceptor(SpecInterceptor::class)]
final readonly class Spec implements Interceptable
{
/** @var list<non-empty-string> */
public array $tags;

/**
* @param non-empty-string $story The user story or specification fragment, written in Markdown. This is the
* behaviour the test verifies — keep it human-readable, it ends up verbatim in the report.
* @param list<non-empty-string> $tags Free-form labels (e.g. a feature key, a Jira id) used to
* group or filter fragments in generated documents.
*/
public function __construct(
public string $story,
array $tags = [],
) {
\trim($story) === '' and throw new \InvalidArgumentException('Spec story must not be empty.');

$this->tags = \array_values(\array_filter(
$tags,
static fn(string $tag): bool => $tag !== '',
));
}
}
44 changes: 44 additions & 0 deletions plugin/spec/composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "testo/spec",
"description": "Spec-driven plugin for the Testo testing framework: attach BDD/spec fragments to tests and generate living documentation.",
"license": "BSD-3-Clause",
"type": "library",
"keywords": [
"testo",
"spec",
"bdd",
"documentation",
"test"
],
"authors": [
{
"name": "Aleksei Gagarin (roxblnfk)",
"homepage": "https://github.com/roxblnfk"
}
],
"funding": [
{
"type": "boosty",
"url": "https://boosty.to/roxblnfk"
}
],
"require": {
"php": ">=8.2",
"testo/testo": "0.10.26 - 1"
},
"autoload": {
"psr-4": {
"Testo\\Spec\\": "src/"
},
"files": [
"Spec.php"
]
},
"minimum-stability": "dev",
"prefer-stable": true,
"extra": {
"branch-alias": {
"dev-1.x": "1.x-dev"
}
}
}
44 changes: 44 additions & 0 deletions plugin/spec/src/Internal/SpecCaseOrderInterceptor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace Testo\Spec\Internal;

use Testo\Core\Context\CaseInfo;
use Testo\Core\Context\CaseResult;
use Testo\Core\Definition\TestDefinition;
use Testo\Pipeline\Middleware\TestCaseRunInterceptor;

/**
* Reorders the tests within a Test Case by their spec item number before the case runs.
*
* Uses the same {@see SpecNumberer} ordering as the generated document: items are ordered by their
* effective number (a method-pinned number, otherwise `{section}.{source-line-rank}`), tests without
* a spec number keep source order and fall to the end. When no numbers are involved this is a no-op.
*
* @internal
* @psalm-internal Testo\Spec
*/
final readonly class SpecCaseOrderInterceptor implements TestCaseRunInterceptor
{
#[\Override]
public function runTestCase(CaseInfo $info, callable $next): CaseResult
{
$section = SpecHeaderReader::section($info->definition->reflection)?->number;

$items = [];
foreach ($info->definition->tests->getTests() as $name => $definition) {
$items[$name] = [
'number' => SpecHeaderReader::item($definition->reflection)?->number,
'line' => $definition->reflection->getStartLine() ?: 0,
];
}

$position = \array_flip(SpecNumberer::orderKeys($items, $section));

$info->definition->tests->sort(static fn(TestDefinition $a, TestDefinition $b): int =>
($position[$a->reflection->getShortName()] ?? 0) <=> ($position[$b->reflection->getShortName()] ?? 0));

return $next($info);
}
}
Loading
Loading