This article isn’t about testing. It’s about adopting a workflow that keeps you in control while developing your feature. Tests are just the engine and the happy consequence of this process.
This workflow has completely transformed the way I code, and it never fails to put a smile on my face. My hope is that it does the same for you. By the end, you’ll have a fully developed feature that satisfies all business rules and a test suite that validates it in less than a second.
I used PHP for the demonstration, but this workflow is fully adaptable to any language.
The Workflow and Testing Frustrations
Where do I start my feature?
When it’s time to develop a new feature, it’s often hard to know where to begin. Should you start with the business logic, the controller, or the front end ?
It’s equally tricky to know when to stop. Without a clear process, the only way to gauge your progress is through manual testing. A tedious and error-prone approach.
Don’t be scare, it’s just a 2,000-line file, without any tests 😁
You know the one. That file where unexplainable things happen. You need to change a single line, but every attempt seems to break the entire project. Without a safety net of tests, refactoring feels like walking a tightrope over a canyon.
Tests are painfully slow
To avoid this chaos, you decide to test every feature with end-to-end tests. Great idea ! Until you realize you have enough time to drink five cups of coffee while waiting for the test suite to finish. Productivity ? Out the window.
Tests Break After Every Refactor
In an effort to create instant feedback, you decide to write fine-grained tests for all your classes. However, now every change you make leads to a cascade of broken tests, and you end up spending more time fixing them than working on the actual change. It’s frustrating, inefficient, and makes you dread every refactor.
Let's Dream ! What the perfect test ?
Instant feedback
Feedback is key at every stage of software development. While developing the core of the project, I need instant feedback to know immediately if any of my business rules are broken. During refactoring, having an assistant that informs me whether the code is broken or if I can proceed safely is an invaluable advantage.
Focus on the behaviors
What the project is able to do, the project behaviors, is the most important aspect. These behaviors should be user-centric. Even if the implementation (the code created to make a feature work) changes, the user's intention will remain the same.
For example, when a user places a product order, they want to be notified. There are many ways to notify a user: email, SMS, mail, etc. While the user's intention doesn't change, the implementation often will.
If a test represents the user's intention and the code no longer fulfills that intention, the test should fail. However, during refactoring, if the code still meets the user's intention, the test should not break.
The Copilot
Imagine being guided step by step through the creation of a new feature. By writing the test first, it becomes your guide to coding the correct feature. It might not be entirely clear yet, but I want my test to function as my GPS while I code. With this approach, I don’t need to think about the next step, I simply follow the GPS instructions. If this concept still feels unclear, don’t worry ! I’ll explain it in detail in the workflow section. 😉
Few Requirements before Starting
The Feature and the Acceptance Tests
As I mentioned in the introduction, this article isn’t about testing—it’s about creating a feature. And to do that, I need a feature. More specifically, I need a feature accompanied by acceptance tests defined with examples. For each feature, the team should establish acceptance criteria or rules, and for each rule, create one or more examples.
⚠️ These examples must be user/domain-centric, with no technical aspects described. A simple way to check if your examples are well-defined is to ask yourself: "Would my example work with any implementation (Web front-end, HTTP API, Terminal CLI, Real life, etc.)?"
For instance, if I want to develop the "Add product to basket" feature, my examples could look like this:
These examples will serve as the foundation for the tests. For more complex scenarios, I use the Given/When/Then pattern for additional detail, but it’s not necessary for simpler ones.
Domain-centric architecture
Instant feedback and a focus on behaviors, isn’t that quite a challenge ? With the Symfony MVC + Services architecture, achieving this can be difficult. That’s why I’ll use a domain-focused architecture, which I’ve detailed in this article: Another Way to Structure Your Symfony Project.
Behaviors focused tests
To focus on behaviors, I need to structure the tests the right way. First, I describe the state of the system before the user action. Next, I execute the user action, which changes the state of the system. Finally, I assert that the system's state matches my expectations.
By testing this way, the test doesn’t care how the user action is handled by the system; it only checks whether the action succeeds or not. This means that if we change the implementation of the user action, the test won’t break. However, if the implementation fails to update the system’s state as expected, the test will break—and that’s exactly what we want!
Fake the I/O
To achieve instant feedback, mocking certain parts of the system is needed. A domain-centric architecture makes this possible because the domain relies solely on interfaces to interact with external libraries. This makes it incredibly easy to fake those dependencies, allowing the functional tests to run super fast. Of course, the real implementations will also be tested (though not in this article) using integration tests.
The GPS Workflow
In this workflow, the test is my GPS ! I set a destination, let it guide me, and notifies me when I’ve arrived. The test will take shape based on the example the team provides.
Enter your Destination
To test the business logic and the user intention, I use a functional test :
// tests/Functional/AddProductToBasketTest.php
namespace App\Tests\Functional;
use PHPUnit\Framework\TestCase;
class AddProductToBasketTest extends TestCase
{
public function testAddProductToBasket() {
}
}
This file will contain all the tests for this feature, but let's start with the first one.
Arrange
In the first part, I describe the state of the application. Here, there is an empty basket for the customer with the id "1".
$customerId = 1;
$productId = 1;
$basketId = 1;
$basket = new Basket($basketId, customerId: $customerId);
$inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]);
Act
The handler and the command represent the user intention, like this it's explicit and everyone understand.
$commandHandler = new AddProductToBasketCommandHandler(
$inMemoryBasketRepository,
);
$commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId));
📝 : I decided to separate the commands and the queries in this project, but we could have a AddProductToBasketUseCase.
Assert
Finally, it's time to describe what the final result should look like. I expect to have the right product in my basket.
$expectedBasket = new Basket($basketId, $customerId, array($productId));
$this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]);
The Destination
Here is the test that translates the first example into code.
// tests/Functional/AddProductToBasketTest.php
namespace App\Tests\Functional;
use PHPUnit\Framework\TestCase;
class AddProductToBasketTest extends TestCase
{
public function testAddProductToBasket(): void {
// Arrange
$customerId = 1;
$productId = 1;
$basketId = 1;
$basket = new Basket($basketId, customerId: $customerId);
$inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]);
// Act
$commandHandler = new AddProductToBasketCommandHandler(
$inMemoryBasketRepository,
);
$commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId));
// Assert
$expectedBasket = new Basket($basketId, $customerId, array($productId));
$this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]);
}
}
Don't be Scared of Errors
At this point, my IDE is showing errors everywhere because nothing I used in this test exists yet. But are these really errors, or are they simply the next steps toward our destination ? I treat these errors as instructions, each time I run the test, it will tell me what to do next. This way, I don’t have to overthink; I just follow the GPS.
💡 Tip: To improve the developer experience, you can enable a file watcher that automatically runs the test suite whenever you make a change. Since the test suite is extremely fast, you’ll get instant feedback on every update.
In this example, I’ll address each error one by one. Of course, if you feel confident, you can take shortcuts along the way. 😉
So, let’s run the test ! For each error, I’ll do the bare minimum needed to move on to the next step.
Make the test compile
❌ 🤖 : "Error : Class "App\Tests\Functional\Basket" not found"
Since it's the first test for this feature, most of the classes do not exist yet. So first step, I create a Basket object :
// src/Catalog/Domain/Basket.php
namespace App\Catalog\Domain;
class Basket
{
public function __construct(
public readonly string $id,
public readonly string $customerId,
public array $products = [],
)
{
}
}
❌ 🤖 : "Error : Class "App\Tests\Functional\InMemoryBasketRepository" not found"
I create the InMemoryBasketRepository :
// src/Catalog/Infrastructure/Persistence/InMemory/InMemoryBasketRepository.php
namespace App\Catalog\Infrastructure\Persistence\InMemory;
class InMemoryBasketRepository
{
public function __construct(
public array $baskets
)
{
}
}
❌ 🤖 : "Error : Class "App\Tests\Functional\AddProductToBasketCommandHandler" not found"
I create the AddProductToBasketCommandHandler in the Catalog/Application folder, this handler will be the entry point for the "Add product to basket" feature.
// src/Catalog/Application/Command/AddProductToBasket/AddProductToBasketCommandHandler.php
namespace App\Catalog\Application\Command\AddProductToBasket;
use App\Catalog\Domain\BasketRepository;
class AddProductToBasketCommandHandler
{
public function __construct(BasketRepository $basketRepository)
{
}
public function __invoke()
{
}
}
To respect the domain-centric architecture, I create an interface for the repository, like this we invert the dependency.
// src/Catalog/Domain/BasketRepository.php
namespace App\Catalog\Domain;
interface BasketRepository
{
}
❌ 🤖 : "TypeError : App\Catalog\Application\Command\AddProductToBasket\AddProductToBasketCommandHandler::__construct(): Argument #1 ($basketRepository) must be of type App\Catalog\Domain\BasketRepository, App\Catalog\Infrastructure\Persistence\InMemory\InMemoryBasketRepository given"
Now, the InMemory implementation must implement the interface to be able to be injected in the command handler.
...
class InMemoryBasketRepository implements BasketRepository
{
...
}
❌ 🤖 : _"Error : Class "App\Tests\Functional\AddProductToBasketCommand" not found"
_
I create a command, since the a command is always a DTO, I marked it as readonly and put all the properties public.
// src/Catalog/Application/Command/AddProductToBasket/AddProductToBasketCommand.php
namespace App\Catalog\Application\Command\AddProductToBasket;
readonly class AddProductToBasketCommand
{
public function __construct(
public string $customerId,
public string $basketId,
public string $productId,
)
{
}
}
❌ 🤖 : "Failed asserting that two objects are equal."
The test is compiling ! It's still red, so we are not finish yet, let's keep going ! 🚀
Let's Go for the Business Logic
It’s time to code the business logic. Just like when I was writing the test, even though nothing exists yet, I write how I expect my handler to process the command. It won’t compile, but once again, the test will guide me to the next step.
// src/Catalog/Application/Command/AddProductToBasket/AddProductToBasketCommandHandler.php
...
class AddProductToBasketCommandHandler
{
...
public function __invoke(AddProductToBasketCommand $command)
{
$basket = $this->basketRepository->get($command->basketId);
$basket->add($command->productId);
$this->basketRepository->save($basket);
}
}
❌ 🤖 : "Error : Call to undefined method App\Catalog\Infrastructure\Persistence\InMemory\InMemoryBasketRepository::get()"
I create the get method in the repository interface :
// src/Catalog/Domain/BasketRepository.php
namespace App\Catalog\Domain;
interface BasketRepository
{
public function get(string $basketId): Basket;
}
❌ 🤖 : "PHP Fatal error: Class App\Catalog\Infrastructure\Persistence\InMemory\InMemoryBasketRepository contains 1 abstract method and must therefore be declared abstract or implement the remaining methods (App\Catalog\Domain\BasketRepository::get)"
And now I implement it in the InMemory repository. Since it's only to test, I create a really simple implementation :
// src/Catalog/Infrastructure/Persistence/InMemory/InMemoryBasketRepository.php
...
class InMemoryBasketRepository implements BasketRepository
{
...
public function get(string $basketId): Basket
{
foreach ($this->baskets as $basket) {
if($basket->id === $basketId) {
return $basket;
}
}
}
}
❌ 🤖 : "Error : Call to undefined method App\Catalog\Domain\Basket::add()"
I create the add method and implement it on the basket object
// src/Domain/Basket.php
...
class Basket
{
...
public function add(string $productId): void
{
$this->products[] = $productId;
}
}
❌ 🤖 : Error : Call to undefined method App\Catalog\Infrastructure\Persistence\InMemory\InMemoryBasketRepository::save()
Same here, I create the method in the interface and I implement it in the in-memory repository :
// src/Catalog/Domain/BasketRepository.php
interface BasketRepository
{
...
public function save(Basket $basket): void;
}
// src/Catalog/Infrastructure/Persistence/InMemory/InMemoryBasketRepository.php
...
class InMemoryBasketRepository implements BasketRepository
{
...
public function save(Basket $basket): void
{
$this->baskets[] = $basket;
}
}
Destination reached! 🎉 🍾 🥂
With this workflow, I’ve gamified the developer experience. After the challenge of tackling the red test, seeing it turn green gives you a shot of dopamine 💉🎊
Refactoring Time
To make the test pass, I intentionally went a bit fast. Now, I can take the time to refine the test and implement the code in a better way.
The test
The test translate the example but lost the user intention during the translation. With few functions, I can make it much closer to the original example.
Example :
Test :
// tests/Functional/AddProductToBasketTest.php
...
class AddProductToBasketTest extends TestCase
{
public function testAddProductToBasket(): void {
// Arrange
$this->givenAnEmptyBasketExists();
// Act
$this->whenIAddAProductToTheBasket(customerId: "1", basketId: "1" , productId: "1");
// Assert
$this->thenTheProductShouldBeInTheBasket(productId: "1");
}
...
}
The code
This isn’t the focus of this article, so I won’t go into detail, but I could express the domain model more precisely using a rich domain model.
Since the test is green, now I can refactor as much as I want, the test has my back. 🔒
Other examples
As you saw at the beginning of the article, one feature has a lot of examples to describe it. So it's time to implement them all :
// tests/Functional/AddProductToBasketTest.php
...
class AddProductToBasketTest extends TestCase
{
...
public function testAddAProductToTheBasket(): void
{
// Arrange
$customerId = "1";
$basketId = "1";
$productId = "1";
$this->givenAnEmptyBasketExists($basketId);
$this->givenAProductExists($productId);
$this->givenACustomerExists($customerId);
// Act
$this->whenIAddAProductToTheBasket(customerId: "1", basketId: $basketId, productId: $productId);
// Assert
$this->thenTheProductShouldBeInTheBasket($basketId, productId: "1");
}
public function testAddProductAlreadyInTheBasketToTheBasket(): void { ... }
public function testAddProductThatDoesNotExistInTheBasket(): void { ... }
public function testAddProductToBasketWithCustomerNotExists(): void
{
// Arrange
$customerId = "1";
// Assert
$this->thenIShouldGetAnError(CustomerNotFound::class, "Customer with id {$customerId} not found");
// Arrange
$productId = "1";
$this->givenNoCustomerExists();
$this->givenNoBasketExists();
$this->givenAProductExists($productId);
// Act
$this->whenIAddAProductToTheBasket(customerId: "1", basketId: "1", productId: $productId);
}
...
I’ve followed this workflow multiple times with different tests, and various parts of my code have evolved to align with the business rules. As you saw with the first test, my objects were very simple and sometimes even basic. However, as new business rules were introduced, they pushed me to develop a much smarter domain model. Here’s my folder structure :
To simplify testing while preserving the encapsulation of domain models, I introduced the builder and snapshot patterns.
// tests/Builder/BasketBuilder.php
namespace App\Tests\Builder;
use App\Catalog\Domain\Basket\BasketSnapshot;
class BasketBuilder
{
private string $id = "1";
private string $customerId = "1";
private array $entries = [];
public function buildSnapshot(): BasketSnapshot
{
return new BasketSnapshot(
$this->id,
$this->customerId,
$this->entries
);
}
public function setId(string $id): BasketBuilder
{
$this->id = $id;
return $this;
}
public function setCustomerId(string $customerId): BasketBuilder
{
$this->customerId = $customerId;
return $this;
}
public function setEntries(array $basketEntries): BasketBuilder
{
$this->entries = $basketEntries;
return $this;
}
}
// src/Catalog/Domain/Basket/BasketSnapshot.php
namespace App\Catalog\Domain\Basket;
readonly class BasketSnapshot
{
public function __construct(
public string $id,
public string $customerId,
public array $entries
)
{
}
}
// src/Catalog/Domain/Basket/Basket.php
namespace App\Catalog\Domain\Basket;
class Basket
{
private array $entries = [];
public function __construct(
private readonly string $id,
private readonly string $customerId,
)
{
}
public function add(string $productId): void
{
if(isset($this->entries[$productId])) {
$this->entries[$productId]++;
}
else {
$this->entries[$productId] = 1;
}
}
public static function fromSnapshot(BasketSnapshot $snapshot): Basket
{
$basket = new Basket(
$snapshot->id,
$snapshot->customerId,
);
$basket->entries = $snapshot->entries;
return $basket;
}
public function getSnapshot(): BasketSnapshot
{
return new BasketSnapshot(
$this->id,
$this->customerId,
$this->entries
);
}
}
How I use it :
// tests/Functional/AddProductToBasketTest.php
...
public function givenAnEmptyBasketExists(string $basketId): void
{
$basketSnapshot = (new BasketBuilder())
->setId($basketId)
->buildSnapshot();
$this->inMemoryBasketRepository = new InMemoryBasketRepository([$basketSnapshot->id => $basketSnapshot]);
}
...
Speed ⚡
Workflow
Since I’m completely focused on the business logic, my test guides me at every step, telling me what to do each time I add new code. Additionally, I no longer need to perform manual testing to ensure everything works, which significantly improves my efficiency.
In this article, I demonstrated a very simple example where I mostly created classes. However, with more complex business logic, each new rule will add complexity to the domain model. During refactoring, my goal is to simplify the domain model while keeping the tests green. It’s a real challenge—but an incredibly satisfying one when it finally works.
What I’ve shown here is just the first part of the complete workflow. As I mentioned, to have a fully functional feature, I still need to implement the real adapters. In this case, that would include creating the repositories (Basket, Customer, and Product) using a test-first approach with a real database. Once that’s complete, I would simply configure the real implementations in the dependency injection configuration.
Testing
I used pure PHP without relying on any framework, focusing solely on encapsulating all the business logic. With this strategy, I no longer have to worry about test performance. Whether there are hundreds or even thousands of tests, they would still run in just a few seconds, providing instant feedback while coding.
To give you a hint of the performance, here’s the test suite repeated 10,000 times:
4 seconds... ⚡
Concepts
This workflow sits at the intersection of several concepts. Let me introduce some of them so you can explore them further if needed.
Example mapping
To describe a feature, the best approach is to provide as many examples as necessary. A team meeting can help you gain a deep understanding of the feature by identifying concrete examples of how it should work.
The Given-When-Then framework is a great tool for formalizing these examples.
To translate these examples into code, the Gherkin language (with Behat in PHP) can be helpful. However, I personally prefer to work directly in PHPUnit tests and name my functions using these keywords.
Go Further :
- Matt Wynne - Introducing Example Mapping
- Aslak Hellesøy - Introducing Example Mapping
- Kenny Baas-Schwegler - Crunching 'real-life stories' with DDD & Event Storming
- Martin Fowler - Given, When, Then
F.I.R.S.T
The "what do we want ?" part could be summarize with the acronym F.I.R.S.T :
- Fast
- Isolated
- Repeatable
- Self-validating
- Thorough
Now you have a checklist to know if your tests are well made.
Go further
- Robert C. Martin - Clean Code, Chapter 9 : Unit Tests, F.I.R.S.T
Ports and Adapters, Hexagonal, Onion, and Clean Architecture
All these architectures aim to isolate business logic from implementation details. Whether you use Ports and Adapters, Hexagonal, or Clean Architecture, the core idea is to make the business logic framework-agnostic and easy to test.
Once you have this in mind, there is a full spectrum of implementations, and the best one depends on your context and preferences. A major advantage of this architecture is that, by isolating the business logic, it enables much more efficient testing.
Go Further
- Alistair Cockburn - Hexagonal Architecture
- Uncle Bob - Clean Architecture
- Herberto Graca - DDD, Hexagonal, Onion, Clean, CQRS, … How I Put It All Together
Outside-in Diamond Test Strategy
I guess your already familiar with the pyramid of test, instead I prefer to use the diamond representation. This strategy, described by Thomas Pierrain, is use-case driven and focus on the behaviors of our application.
This strategy also encourages black-box testing of the application, focusing on the results it produces rather than how it produces them. This approach makes refactoring significantly easier.
Go Further
- Thomas Pierrain - Outside-in Diamond 🔷 TDD #1 - a style made from (& for) ordinary people
- Thomas Pierrain - Outside-in Diamond 🔷 TDD #2 (anatomy of a style)
- Thomas Pierrain - Write Antifragile & Domain-Driven tests with ”Outside-in diamond” ◆ TDD
Wishful thinking programming
As you saw throughout the workflow, in the test and in the command handler, I always wrote what I wanted before it even existed. I made some "wishes". This approach is known as wishful thinking programming. It allows you to imagine the structure of your system and the ideal way to interact with it before deciding how to implement it. When combined with testing, it becomes a powerful method to guide your programming.
Arrange, Act, Assert
This pattern helps structure tests effectively. First, the system is set to the correct initial state. Next, a state modifier, such as a command or user action, is executed. Finally, an assertion is made to ensure the system is in the expected state after the action.
Go further
Final Thoughts
We’ve walked through how a domain-focused architecture combined with acceptance test driven development can transform your development experience. Instant feedback, robust tests, and a focus on user intentions make coding not just more efficient but more enjoyable.
Try this workflow, and you might find yourself smiling every time a test turns green ✅ ➡️ 😁
Let me know in the comments what would be your perfect workflow or what would you improve in this one !
Top comments (0)