Home >Backend Development >PHP Tutorial >Test All your Business Logic in less than econd
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.
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.
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.
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.
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.
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.
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.
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. ?
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.
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.
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!
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.
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.
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.
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]);
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.
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]);
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]); } }
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 us 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.
❌ ? : "Error : Class "AppTestsFunctionalBasket" 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 :
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket() { } }
❌ ? : "Error : Class "AppTestsFunctionalInMemoryBasketRepository" not found"
I create the InMemoryBasketRepository :
$customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]);
❌ ? : "Error : Class "AppTestsFunctionalAddProductToBasketCommandHandler" 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.
$commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId));
To respect the domain-centric architecture, I create an interface for the repository, like this we invert the dependency.
$expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]);
❌ ? : "TypeError : AppCatalogApplicationCommandAddProductToBasketAddProductToBasketCommandHandler::__construct(): Argument #1 ($basketRepository) must be of type AppCatalogDomainBasketRepository, AppCatalogInfrastructurePersistenceInMemoryInMemoryBasketRepository given"
Now, the InMemory implementation must implement the interface to be able to be injected in the command handler.
// 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]); } }
❌ ? : _"Error : Class "AppTestsFunctionalAddProductToBasketCommand" 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/Domain/Basket.php namespace App\Catalog\Domain; class Basket { public function __construct( public readonly string $id, public readonly string $customerId, public array $products = [], ) { } }
❌ ? : "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 ! ?
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/Infrastructure/Persistence/InMemory/InMemoryBasketRepository.php namespace App\Catalog\Infrastructure\Persistence\InMemory; class InMemoryBasketRepository { public function __construct( public array $baskets ) { } }
❌ ? : "Error : Call to undefined method AppCatalogInfrastructurePersistenceInMemoryInMemoryBasketRepository::get()"
I create the get method in the repository interface :
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket() { } }
❌ ? : "PHP Fatal error: Class AppCatalogInfrastructurePersistenceInMemoryInMemoryBasketRepository contains 1 abstract method and must therefore be declared abstract or implement the remaining methods (AppCatalogDomainBasketRepository::get)"
And now I implement it in the InMemory repository. Since it's only to test, I create a really simple implementation :
$customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]);
❌ ? : "Error : Call to undefined method AppCatalogDomainBasket::add()"
I create the add method and implement it on the basket object
$commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId));
❌ ? : Error : Call to undefined method AppCatalogInfrastructurePersistenceInMemoryInMemoryBasketRepository::save()
Same here, I create the method in the interface and I implement it in the in-memory repository :
$expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]);
// 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]); } }
✅ ? :
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 ??
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 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 :
// 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 = [], ) { } }
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. ?
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 :
// src/Catalog/Infrastructure/Persistence/InMemory/InMemoryBasketRepository.php namespace App\Catalog\Infrastructure\Persistence\InMemory; class InMemoryBasketRepository { public function __construct( public array $baskets ) { } }
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/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket() { } }
$customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]);
$commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId));
How I use it :
$expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]);
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.
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:
This workflow sits at the intersection of several concepts. Let me introduce some of them so you can explore them further if needed.
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.
The "what do we want ?" part could be summarize with the acronym F.I.R.S.T :
Now you have a checklist to know if your tests are well made.
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.
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.
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.
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.
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 !
The above is the detailed content of Test All your Business Logic in less than econd. For more information, please follow other related articles on the PHP Chinese website!