Darkwood Blog Blog
  • Articles
  • Watch
  • Releases
  • Creators
en
  • de
  • fr
Login
  • Blog
  • Articles
  • Watch
  • Releases
  • Creators

🧪 From No-Life Testing to Behavioral Testing

on August 2, 2026

Log in to add a reaction to this post

🚀 1

AI made writing tests cheap. Choosing what deserves an oracle is now the hard part.

Your AI writes tests. Are they worth anything?

That is not a rhetorical flourish. It can be argued that weak tests are now a liability. Coding agents change code faster than any human reviewer can follow. The suite is the insurance claim on every change. If the suite only proves that mocks were called in order, or that await() ran once, or that something shaped like a DTO came back - the insurance is theatre.

Sebastian Bergmann says the same thing with different vocabulary. Behind every green bar sits a test oracle: the procedure that decides whether a result is correct. A passing test does not mean the product is right. It means the oracle you chose - consciously or not - was satisfied.

This article is not a PHPUnit tutorial. It is not a TDD sermon. It is not a ReactPHP vs Amp comparison.

It is an opinionated walk through a small companion project - nolife-tests (under Darkwood content/) - that uses Darkwood Flow to make one architectural claim concrete:

Jobs are business behavior. Drivers are runtime furniture. Application suites should pin the former and ignore the latter.

If you finish this piece still proud of every green test in your repository, I have failed. If you finish it quietly deleting a few, or rewriting their assertions, the experiment worked.

The economics flipped

Before agents, good tests were expensive. Writing them took time nobody wanted to volunteer. Teams skipped them and lived with the risk.

Today, writing tests is cheap. Agents will happily flood a pull request with coverage. Potencier notes that the old complaint - “agents write empty assertions and mocks all the way down” - is mostly outdated with current models. Agents can also backfill real suites: find unasserted behavior, pin it, fail for the right reason.

So tests are solved?

No.

What became scarce is judgment: which behaviors deserve insurance, which assertions survive a refactor, which doubles hide a design smell, which green bars would still pass if the product meaning broke.

That distinction is the whole article.

Before AI          →  Cost of writing tests  ≫  Cost of choosing oracles
With coding agents →  Cost of writing tests  ≪  Cost of choosing oracles

Volume without oracles is false insurance. Speed without judgment is how you get a comfort blanket that fails under both production and agents.

What “no-life testing” looks like

I call it no-life testing: a green suite that pins implementation, call order, and runtime plumbing while leaving business meaning unasserted. The tests are alive in CI. The insurance is dead.

The companion repository ships a deliberately educational tests/Bad/ suite. Run it:

cd nolife-tests
composer install
composer test:bad   # green, low value
composer test:good  # behavioral oracles

Three foils. Same workflow. Three different ways to lie.

1. Implementation coupling - mocks all the way down

ImplementationCoupledTest builds a Flow from four mocked JobInterface instances and asserts they were called in order with canned returns. It never asks what the excerpt means.

$fetch->expects($this->once())->method('__invoke')->with($request)->willReturn($raw);
$parse->expects($this->once())->method('__invoke')->with($raw)->willReturn($parsed);
$excerpt->expects($this->once())->method('__invoke')->with($parsed)->willReturn($draft);
$validate->expects($this->once())->method('__invoke')->with($draft)->willReturn($result);

$flow = (new Flow($fetch))->fn($parse)->fn($excerpt)->fn($validate);
($flow)(new Ip($request));
$flow->await();

$this->assertTrue(true); // Green. We never asserted meaning.

Swap the real ParseJob for a broken implementation. Ship garbage HTML parsing. This suite stays green. The mocks never call the real job.

Ask the pressure-test question: If a broken real ParseJob shipped, would this stay green? Yes. So the insurance claim is call order under doubles - not excerpt faithfulness.

2. Runtime coupling - testing the furniture

RuntimeCoupledTest wraps FiberDriver and counts await() calls. It proves the event-loop wrapper was exercised. It does not prove the excerpt is faithful.

public function await(array &$stream): void
{
    ++$this->counter->awaitCalls;
    $this->inner->await($stream);
}

// ...
$this->assertSame(1, $counter->awaitCalls);

ReactPHP does not need your application tests. Amp does not need them either. Flow’s package CI may smoke drivers. Your product suite should not pin how many times the driver drained its stream.

Would Fiber → Amp break this test even when the excerpt is correct? Yes. That is the definition of testing furniture.

3. Coverage theatre - shape without meaning

CoverageTheaterTest asserts instanceof ExcerptResult, non-null title, string excerpt. An agent loves this pattern: high coverage, zero oracle. Change the excerpt algorithm to return "lorem ipsum" and CI stays green.

$this->assertInstanceOf(ExcerptResult::class, $result);
$this->assertNotNull($result->title);
$this->assertIsString($result->excerpt);
// Never asked: is the excerpt faithful to the document?

Bergmann’s framing again: the oracle chose “shape.” Shape is not product truth.

Flow in one diagram: Jobs vs Drivers

Darkwood Flow is not a node/edge DAG library. Work moves as information packets (Ip) through a composed sequence of Jobs. Concurrency is packet scheduling (IpStrategy) plus a pluggable coroutine runtime (Driver).

flowchart TD
    Req[DocumentRequest] --> Fetch[FetchJob]
    Fetch --> Parse[ParseJob]
    Parse --> Excerpt[ExcerptJob]
    Excerpt --> Validate[ValidateJob]
    Validate --> Result[ExcerptResult]

    subgraph behavior [Business behavior - test this]
        Fetch
        Parse
        Excerpt
        Validate
    end

    subgraph furniture [Runtime furniture - do not pin in app tests]
        D1[FiberDriver]
        D2[AmpDriver]
        D3[ReactDriver]
        D4[… future runtime]
    end

    behavior -.->|scheduled by| furniture
Concept Meaning Belongs in app tests?
Job Transform T1 → T2 (parse, excerpt, validate) Yes - unit and workflow oracles
Ip Immutable carrier of current data Indirectly (input/output packets)
Flow Ordered stages composed with fn As a workflow oracle, not via call-order mocks
Driver How async is implemented No - smoke in Flow’s own CI
Port I/O boundary (DocumentSource) Stub here

The architecture already encodes the testing lesson. Business behavior lives in Jobs. The runtime is replaceable. If your tests break when you swap the driver, they were never testing the product.

In the companion project, the workflow is assembled once:

return (new FlowFactory($driver))->create(static function () use ($source, $maxExcerptLength, $minExcerptLength) {
    yield new FetchJob($source);
    yield new ParseJob();
    yield new ExcerptJob($maxExcerptLength);
    yield new ValidateJob($minExcerptLength);
});

Same chain. Pass FiberDriver or AmpDriver. The Jobs do not know which.

php bin/excerpt.php fiber
php bin/excerpt.php amp

Same title. Same excerpt. Different furniture.

Where business lives: test the Jobs

ParseJob, ExcerptJob, and ValidateJob are ordinary callables. No Flow required. No Driver required. That is the point.

JobBehaviorTest asserts meaning:

$parsed = (new ParseJob())(new RawDocument('doc://1', $html));

$this->assertSame('Hello Flow', $parsed->title);
$this->assertSame('Jobs transform packets. Drivers schedule them.', $parsed->body);
$draft = (new ExcerptJob(40))(new ParsedDocument('doc://1', 'T', $longBody));
$this->assertSame('Jobs transform information packets into…', $draft->excerpt);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('too short');
(new ValidateJob(20))(new ExcerptDraft('doc://1', 'T', 'too short'));

These tests would still be correct if Flow disappeared tomorrow. They would still be correct if you ran the Jobs inside a Symfony Messenger worker, a CLI script. They protect observable transforms, not orchestration plumbing.

That is the first half of the mental model:

If you can unit-test a Job without constructing a Flow, you have found business behavior.

Workflows encourage this split. When stages are named transforms with typed packets, “what should we assert?” stops being abstract. You assert what the packet means after the stage.

The workflow oracle

Unit Jobs are necessary. They are not sufficient. Composition can still be wrong: wrong order, missing validation, a port that never gets called.

WorkflowBehaviorTest runs the full chain through Flow and asserts faithfulness against fixtures/article.html:

$result = FlowCollector::run($flow, new Ip(new DocumentRequest('fixture://article')));

$this->assertSame('Darkwood Flow Notes', $result->title);
$this->assertStringContainsString('Jobs transform information packets', $result->excerpt);
$this->assertStringContainsString('Drivers schedule', $result->excerpt);
$this->assertLessThanOrEqual(121, mb_strlen($result->excerpt));

A second case feeds a thin document and expects the validation failure - not a happy-path shape check.

Notice what is not asserted: call order, driver type, await() counts, mock expectations on Jobs.

Honesty about Ip

Ip::$data is readonly. Flow does not mutate your original packet; each stage wraps a new Ip. Pretending the input packet changed is a common false pattern.

FlowCollector appends a terminal job that stores the final payload - the same honesty Flow’s own tests use:

$flow->fn(static function (mixed $data) use ($box): mixed {
    $box->value = $data;
    return $data;
});
($flow)($ip);
$flow->await();
return $box->value;

Optional Flow wishlist (not required for the argument): a sync run(Ip): mixed helper for tests. Until then, collect explicitly. Do not invent mutation that the model does not have.

Swap the runtime. Keep the tests.

Here is the punch line of the repository - and of this article.

MultiDriverBehaviorTest runs identical assertions under Fiber and Amp:

#[DataProvider('provideDrivers')]
public function testSameExcerptUnderDifferentDrivers(DriverInterface $driver): void
{
    $flow = (new ExcerptWorkflowFactory())->create($driver, $source, maxExcerptLength: 120);
    $result = FlowCollector::run($flow, new Ip(new DocumentRequest('fixture://article')));

    $this->assertSame('Darkwood Flow Notes', $result->title);
    $this->assertStringContainsString('Jobs transform information packets', $result->excerpt);
    $this->assertStringEndsWith('…', $result->excerpt);
}

public static function provideDrivers(): iterable
{
    yield 'fiber' => [new FiberDriver()];
    yield 'amp' => [new AmpDriver()];
}
flowchart LR
    Oracle[Same behavioral oracle] --> Fiber[FiberDriver]
    Oracle --> Amp[AmpDriver]
    Fiber --> Green1[Green]
    Amp --> Green2[Green]

    Bad[await / driver spies] --> Fiber
    Bad --> Red[Red after swap]

If changing the runtime requires rewriting the tests, the tests were coupled to implementation.

That sentence applies beyond Flow:

If you switch from… …and your suite breaks while product meaning is unchanged…
Sequential PHP You were testing the call stack, not the outcome
ReactPHP You were testing promises / loop furniture
Amp You were testing Amp types, not domain packets
Any future runtime Same diagnosis

This is why a “ReactPHP vs Amp” article is the wrong frame for testing. Performance, DX, and ecosystem matter for runtime choice. They should almost never appear in application oracles.

Flow’s design makes the experiment cheap: inject the driver at the factory boundary. The Jobs stay portable. The suite stays stable.

Mocks under agents

Mocks are not evil. Unexamined mocks under agent velocity are.

Bergmann’s stub/mock distinction matters more when an agent can generate fifty expectation objects in a minute. A stub at a port replaces an I/O boundary with a controlled fake and keeps real Jobs. A mock chain replaces collaborators with expectation objects and often hides the behavior you meant to protect.

In nolife-tests, the port is DocumentSource. The good suite stubs it:

$source = new InMemoryDocumentSource(['fixture://article' => $html]);

FetchJob still runs. ParseJob still runs. The network does not.

Contrast with mocking FetchJob itself: you stop executing the URI → RawDocument mapping. You also invite the agent to mock the next job, and the next, until nothing real remains - Potencier’s “mocks all the way down,” now produced at machine speed.

Rule of thumb used in the companion skills:

Do Don’t
Stub DocumentSource / use InMemoryDocumentSource Mock DriverInterface in app tests
Unit-test App\Job\* transforms Assert mock call order across the job chain
Run composer test:good as required CI Treat composer test:bad as insurance

When mocks proliferate, ask whether they are hiding a design problem. If you cannot name a port, you may not have one - and the test is inventing isolation the architecture never earned.

Pressure-test the decision, don’t generate the suite

Guillaume Moigneu’s pressure-test-decisions skill is a Socratic protocol for hard choices: frame the decision, audit assumptions, generate real options, force closure into a keep / experiment / information-action record.

Agents should not only write tests. They should grill the testing decision before emitting more green bars.

The companion repository vendors that skill and adds a specialization: pressure-test-testing-decisions. Same discipline; domain is keep / rewrite / delete / experiment on tests, mocks, oracles, and runtime coupling.

npx skills add . --list
# pressure-test-decisions
# pressure-test-testing-decisions

Example prompts against this repo:

Use $pressure-test-testing-decisions on tests/Bad/ImplementationCoupledTest.php —
should we keep, rewrite, or delete it?
Use $pressure-test-testing-decisions: stub DocumentSource or mock FetchJob?

The skill vocabulary matches the article on purpose:

Term Meaning
Oracle Assertion that fails if business meaning goes wrong
Furniture Runtime / driver / call-order detail that is not the product
Coverage theatre Shape / null / instanceof checks that stay green on wrong meaning
Driver swap Same workflow under Fiber vs Amp; business tests survive

A condensed session from the repo’s examples ends like this for the implementation-coupled test:

Field Entry
Decision keep as educational Bad only / never gate merges on it
Product bug that would stay green Broken real ParseJob / wrong excerpt meaning
Next action Confirm composer test:good is the required CI job

That is the shift: from “generate tests” to “defend the insurance claim.”

Potencier’s advice for new work still stands - have the agent write the test first and watch it fail for the right reason. The pressure-test layer adds the prior question: is this the right reason to care?

Useful grill questions, one at a time:

  • Should this be tested at all?
  • What behavior are we protecting?
  • Would this test fail for the right reason?
  • Does this assertion survive a refactoring?
  • Are we protecting architecture or implementation?
  • Are these mocks hiding a design problem?
  • If we swapped Fiber for Amp, would this fail - and should it?
  • Six months from now: suite green, product wrong - which test lied?

What to ask of an agent

If you only change one habit after reading this, change the brief you give the agent.

Weak brief

Add unit tests for the excerpt workflow. Aim for high coverage.

Strong brief

Pressure-test whether we need a new test. If yes, write a behavioral oracle for Job or workflow meaning. Stub DocumentSource. Do not mock Jobs. Do not assert on the driver. Prefer Red on excerpt faithfulness before any implementation change.

Map the brief to the repo’s suites:

flowchart TD
    Ask[Agent proposes a test] --> PT{Pressure-test decision}
    PT -->|furniture / theatre| Del[Delete or park under Bad]
    PT -->|Job meaning| Job[JobBehaviorTest style]
    PT -->|composition meaning| Wf[WorkflowBehaviorTest style]
    PT -->|runtime claim| Swap[Prove it with MultiDriverBehaviorTest - same assertions]

TDD still works. Red on Job behavior first. Never Red on driver spies. Strengthen oracles, not coverage percentages.

Bergmann’s adjacent warnings fit the same posture: slow suites become a coverage ceiling for LLM reviewers (dropped hypotheses leave no ticket); “faster than understanding” means agents type in minutes what humans need hours to verify; untouched tests are half the proof - if a fix rewrites the suite, diagnose coupling before celebrating.

Honest limits

This companion does not prove everything. Naming the edges keeps the argument honest.

  • No sync driver yet in Flow for a one-liner run(Ip). FlowCollector is the explicit workaround
  • Stubs at ports remain useful. Abandoning all doubles is not the claim. Abandoning mock chains that erase Jobs is
  • Driver libraries still need tests - in the Flow package, not in every application suite. Matrix every driver in product CI only if the product is the driver
  • Amp vs Fiber performance was not measured. Survival of oracles is the point, not a benchmark
  • Concurrency strategies (MaxIpStrategy, etc.) belong in Flow’s own contracts. Application tests should care about concurrency only when concurrency is the product behavior
  • AI can write good tests. The risk is volume without oracles - false insurance under agent velocity - not the impossibility of genuine ones

What the prototype does show: a readable workflow, two runtimes, three worthless greens, three behavioral oracles, and skills that challenge keep/rewrite/delete before more PHPUnit files appear.

Clone it. Break it. Decide.

The repository is not an appendix. It is the laboratory for this article.

composer install
composer test:good   # behavioral insurance
composer test:bad    # educational false insurance
php bin/excerpt.php fiber
php bin/excerpt.php amp

Then open tests/Bad/ImplementationCoupledTest.php, break the real ParseJob on purpose, and watch which suites notice. That break-the-code experiment is worth more than another coverage report.

Explore skills/ and run a pressure-test session on one of your own green tests. Keep the decision record. Delete something that only polishes furniture.

Conclusion

Most unit tests in agent-touched codebases are testing the wrong thing - not because PHPUnit is weak, but because the oracle chose implementation and runtime over meaning.

Flow already draws the line: Jobs transform packets; Drivers schedule them. Workflows make behavior visible as a chain of named transforms. That visibility is a testing advantage if you use it - and a trap if you mock the chain into oblivion.

AI changed the economics. Writing tests is no longer the bottleneck. Choosing trustworthy oracles is.

Insurance that survives a driver swap is insurance on your product. Everything else is furniture polish.

Your AI writes tests. Make sure they are worth claiming.

Sources

  • Sebastian Bergmann : Seeing the Truth: Test Oracles
  • Sebastian Bergmann : The Stub/Mock Intervention
  • Sebastian Bergmann : Faster than understanding
  • Sebastian Bergmann : Speed as a security feature
  • Sebastian Bergmann : Beyond Best Practices
  • Guillaume Moigneu : pressure-test-decisions (Agent Skills)
  • Darkwood : Flow documentation
  • Github repository : NoLife Tests

Log in to add a reaction to this post

🚀 1

Site

  • Sitemap
  • Contact
  • Legal mentions

Network

  • Hello
  • Blog
  • Apps
  • Photos

Social

Darkwood 2026, all rights reserved