Let Your Tests Do The Talking: Listen to Their Feedback!
Testing is more than just detecting errors; they are an ongoing dialogue, revealing hidden design flaws, customer misalignments, and anticipating future problems.
Tests are often seen as the last line of defense between newly written code and its release. But the real 'gain' comes when we start to 'listen' to what the tests are trying to tell us, going beyond simple pass/fail verification and diving into the deep insights they offer about the quality of our design, the accuracy of our logic, and the robustness of our implementation.
Software tests, especially those integrated into agile methodologies and test-driven development practices, are much more than a checkpoint before deployment. They are an ongoing conversation, a constant feedback loop that, if properly interpreted, can reveal much more than the presence of bugs. They can point out design flaws, misalignments with client requirements, and even anticipate issues that have yet to surface in our work.
However, this wealth of information becomes accessible only to those who are willing to 'listen' closely. As developers, we need to cultivate the ability to interpret what the tests are trying to communicate, to read between the lines of the results, and to understand that a difficult test to write, a complex mock to create, or a test that fluctuates between success and failure are not just technical obstacles; they are symptoms, signals that, if properly understood, can guide us to cleaner code, more solid designs, and a delivery that truly meets the client's expectations.
This article aims to explore how we can enhance our ability to 'listen' to the tests, opening new pathways to excellence in software development. We will dive into how the difficulty in testing can reflect unnecessary complexities in design. This text is more of a conversation, so I won’t delve into overly technical details. Let's get started!
Listening Beyond the Obvious
There’s a well-known metaphor involving icebergs, where only a small fraction of the ice is visible above the surface, while the majority remains hidden underwater. This teaches us a valuable lesson about perception: what we see on the surface may be just a small part of a much larger reality. Similarly, in software engineering, our 'observations'—in this case, our initial analysis of the code and test results—can deceive us, leading us to believe that everything is in order, when in fact, much larger problems are hidden below the surface, waiting to emerge.
The iceberg metaphor also applies to how we 'listen' to our tests in software development. Just as only the tip of the iceberg is visible, a superficial analysis of test results may seem sufficient at first glance. However, just as we need to explore below the surface of the iceberg to understand its true magnitude, we must 'dive deep' into our tests to uncover the underlying issues that may be hidden.
A superficial analysis can lead to incorrect conclusions, but a detailed investigation of test failures can reveal hidden problems, like cracks in the ice, that would otherwise go unnoticed. This attention is especially crucial because tests—whether they are unit, integration, contract, or end-to-end—communicate more than just pass/fail results.
They alert us to the assumptions that were made, the expectations the system should meet, and the discrepancies between expected and actual outcomes. These tests reveal the complete structure of the iceberg, showing how the system should behave under ideal conditions, but also exposing the edges and limits where failures can occur. If we don’t pay attention to these narratives, we risk ignoring important warning signs that could lead to catastrophic failures when the system is in real operation.
When we consider system design and business logic, tests act as a mirror, reflecting not only the current state of the code but also the design decisions and assumptions we made during development. A test that becomes unduly complex or difficult to write signals not a failure in the test itself, but a failure in our design or coding approach.
This excessive complexity can result from various inadequate practices, such as high coupling between components, insufficient separation of concerns, or business logic deeply embedded in layers that should be agnostic to it.
Similarly, tests that fail to capture and guard against common error conditions, like a null pointer or an undefined variable, reveal a gap in our foresight and planning. As programmers, we may be tempted to think small, focusing on 'happy paths' and ignoring the vast universe of possible failure states. Tests are our safety net, and when they fail to protect us, it is often because we failed to equip them properly.
Among the most common mistakes programmers make when writing unit and end to end tests are:
Insufficient Coverage: Failing to consider and test all possible scenarios, especially edge cases and error conditions.
Excessive Coupling: Writing tests that are too dependent on the internal implementation details, making them fragile in the face of refactoring.
Non-Deterministic Tests: Creating tests that can unpredictably pass or fail due to dependencies on global state, timing, or external data.
Lack of Clarity: Writing tests that do not clearly communicate the intention or requirement they are validating, making them difficult to maintain and understand.
Superficial Tests: Relying too much on tests that only check the most superficial aspects of the code without delving into the underlying logic and business rules.
In the context of end-to-end testing, common errors include:
Inconsistent Environment: Failing to ensure that the test environment closely mirrors the production environment, leading to false positives or negatives.
Improperly Mocked Dependencies: Failing to isolate the component under test from its external dependencies, resulting in tests that focus more on integration than on specific functionality.
Insufficient Test Data: Using a limited test data set that does not capture the diversity of real-world scenarios.
Additionally, tests that are excessively sensitive to refactoring are particularly problematic. They undermine confidence in the test suite and, by extension, in the project itself. When a simple improvement in code clarity or a performance optimization requires a substantial review of the tests, it not only discourages healthy refactoring but also signals that the tests are too entangled in the intricacies of the implementation, rather than focusing on the desired behaviors and outcomes.
Let's talk more about difficult-to-write tests.
When it becomes clear that something is wrong…
Picture yourself sitting in front of your computer, looking at the code you just wrote. You know that tests are important; they are your safety net, ensuring that any future changes won’t break existing functionality. However, as you begin to write the tests, you encounter a series of obstacles. Suddenly, you find yourself lost in a sea of mocks, just to test a single feature. Does this sound familiar?
This scenario is a classic symptom of code design that didn’t take testability into account. Hasty design and architectural decisions can lead to high coupling between classes, making each component dependent on the internal details of others. This interdependence is the root of many pains when writing tests.
Coupling and Separation of Concerns
The principle of separation of concerns is fundamental in software engineering. It involves dividing an application into distinct sections, with each addressing a specific concern. When classes or modules are tightly coupled, changes in one part of the system can trigger a cascade of adjustments in other areas, including tests.
Why is this a problem?
Difficulty in isolating functionality for testing: If you need to instantiate half the system just to test a simple function, something is wrong. Each test should be able to focus on a specific behavior without worrying about the rest of the system.
Extensive mocking: When you find yourself creating mocks upon mocks for the dependencies of a class, it’s a sign that this class is doing too much or is too intertwined with other functionalities in the system.
Test fragility: Tests that are highly coupled to the code they are testing are fragile. Small changes in implementation can break multiple tests, even if the functionality remains the same.
Consider the following hypothetical scenarios where testability is hampered by code design.
1. Shopping Cart: Calculating Taxes, Applying Discounts, and Checkout
Scenario: A Shopping Cart class that calculates taxes, applies discounts, and checkouts: To test any of these functionalities, you need a Shopping Cart instance loaded with products, a tax system, and possibly an external service for payment processing. The difficulty arises when trying to simulate failures. How would you test the behavior of the cart if the payment service is down? You would end up creating a mock for the payment service, another for the tax system, and so on.
The problem here is not only the need for mocks, but the lack of separation of responsibilities. If a single class is responsible for calculating taxes, applying discounts, and checking out, we are dealing with code that has several responsibilities coupled together. This makes the code less modular and more difficult to test, because to verify a functionality, you end up having to mock several parts of the system.
Testability is compromised because complexity increases exponentially when we try to isolate each functionality. One solution would be to apply the single responsibility principle, splitting these responsibilities into separate classes (e.g. TaxCalculator
, DiscountApplier
, PurchaseFinalizer
). This would allow testing each component independently and reduce the need for mocks for unrelated components.
2. User Manager: Creating Users and Sending Emails
Scenario: A User Manager that also sends confirmation emails: Testing the creation of a user might be straightforward, but what if we want to test sending the email? You would need to mock the email service. What if sending the email fails? How would you test that the system behaves correctly in this failure without sending real emails during the tests?
Here, the main problem is the mixing of business logic with infrastructure logic (sending emails). When the User Manager is responsible for both, testability suffers because any user creation test now depends on an external email service.
To improve testability, we should separate the email sending logic into a separate component or service (EmailService). This way, we can test user creation independently of sending emails, and vice versa. Dependency injection can be used to provide a mock of the EmailService during testing, allowing us to test how the system handles failures in sending emails without needing to involve a real email service.
3. Reporting System: Data Extraction, Transformation, and Presentation
Scenario: A Reporting System that extracts, transforms, and presents data: Imagine that this system pulls data from multiple sources, transforms that data, and then generates a report. To test just the report generation, you would need to mock the data extraction and transformation, which can become a herculean task if these operations are complex.
This scenario highlights the lack of modularity and proper encapsulation of the process steps. The difficulty in testing report generation arises because extraction, transformation, and presentation are tightly coupled. This leads to complex dependencies, where each part of the process needs to be mocked to test the other.
To improve testability, it is essential to divide the process into separate, well-defined components: DataExtractor
, DataTransformer
, and ReportGenerator
. Each of these components can be tested independently. Additionally, by mocking the interfaces of these steps, you can test ReportGenerator without worrying about the complexity of the extraction and transformation operations, focusing only on the specific report generation logic.
In each of these examples, insufficient separation of concerns leads to a situation where testing a specific behavior requires a disproportionate amount of setup and mocks. This not only makes the tests harder to write and maintain, but also makes them less reliable because they are further removed from a real-world usage scenario.
But what are we missing?
When you find yourself stuck in the complexity of tests, as if navigating a maze, don’t get discouraged. This is an important signal—a light at the end of the tunnel. Your tests are trying to tell you something crucial: they are highlighting issues in your code’s design that may not be immediately apparent. This is a critical moment, an opportunity to adjust your code accordingly.
Kent Beck, one of the pioneers of extreme programming and test-driven development (TDD), often emphasizes the importance of listening to what your tests are trying to say. When writing tests becomes a burden, when each test seems to require a disproportionate amount of setup and configuration, it’s a clear sign that your design might be suffering or that you’re focusing on testing something that perhaps shouldn’t be tested. Beck encourages us to view every testing difficulty not as an obstacle, but as a guide, pointing to areas of our code that can be improved.
Martin Fowler, another giant in the world of software design and refactoring, also addresses this issue, highlighting that code should be designed to facilitate testing. When that’s not the case, when tests become complex and fragile, it’s an indication that the code’s design might be violating fundamental principles of good design, such as separation of concerns and low coupling.
The problem, however, is that many developers, perhaps due to deadline pressure or lack of experience, tend to ignore these signals. Instead of viewing testing difficulty as valuable feedback, they see it as a nuisance or, worse, as a justification to skip tests entirely. This is a critical mistake. Ignoring test feedback is like ignoring the warning light on your car’s dashboard. You may be able to keep driving for a while, but eventually, the ignored problems will manifest in possibly catastrophic ways.
The reality is that tests offer a unique opportunity to validate not just the behavior of a feature but also the quality of the code’s design. When a test is hard to write, when you have to contort through multiple layers of mocks, or when a small tweak in the code causes multiple tests to fail, these are all signs that something fundamental might need adjustment in your system’s design.
But we must always be careful with fallacies; let me explain better what I mean.
The Great Fallacy About Unit Tests
Our conversation about tests and code design now extends to a controversial topic that I must address: the fallacy surrounding unit tests. Many of us have grown in our careers learning about the importance of testing our code. However, a mistaken belief often creeps into our development practice, the idea that as long as our unit tests are passing, our code is solid and its design is sound. Some believe that if all the unit tests are 'green,' then everything is fine with our code. This mindset can be misleading and dangerous.
The Illusion of Security
First and foremost, unit tests are essential. They are the guardians of the expected behavior of small units of code, ensuring that each function or method works as it should. However, believing that passing unit tests equates to a completely healthy system is an oversimplification. This belief creates a false sense of security.
Think of unit tests as checking each word in a book. Each word may be correct, but that doesn't guarantee that the sentences make sense, that the paragraphs are coherent, or that the overall story is engaging and free of contradictions.
In modern systems, especially those built with complex architectures like microservices, true robustness is tested in the integrations and the behavior of the system as a whole. Unit tests can validate that each service works in isolation, but what happens when they start interacting? What if the communication between these services fails, or if the shared data isn't in the expected format?
The fallacy of unit tests overlooks these complexities. It disregards the fact that the real challenge often lies in the subtleties of interactions between units of different services, not just within each one.
Moreover, the unit test fallacy can mask design problems. A codebase can have 100% test coverage and still be riddled with poor practices, such as high coupling, low cohesion, or violations of the single responsibility principle. These issues are not necessarily captured by unit tests but have a significant impact on the maintainability and scalability of the system.
Behavior versus Implementation
Another aspect that deserves attention is the difference between testing behaviors and testing implementations. Many unit tests end up focusing on how the code performs a task rather than on what that task actually is. This leads to a situation where trivial changes in implementation result in test failures, even when the external behavior of the code remains unchanged. This is a critical distinction because a good test should allow the code to be refactored without failing, as long as the desired behavior is maintained.
Unit tests are essential, no doubt, but this doesn't mean we should rely solely on them or be content with writing basic scenarios. What unit tests do goes beyond what some may think—they reveal whether our code is maintainable.
The Illusion of Coverage
One of the manifestations of this fallacy is the obsession with test coverage. While a high percentage of code coverage may seem reassuring, it doesn't guarantee that the critical behaviors of the system are properly validated. Code coverage tells us that parts of the code were executed during testing, but it doesn't necessarily mean that the tests are meaningful or that they validate the correct expectations. It's entirely possible to have a system with 100% test coverage that is still riddled with bugs.
Overemphasis on Implementation Details
Another trap of unit tests is the overemphasis on implementation details. Tests that are tightly coupled to the code they are testing tend to be fragile and require constant maintenance. Additionally, they can blind us to the bigger picture, causing us to lose sight of the system's overall behavior. When tests break due to changes that don't affect the observable behavior of the system, they cease to be useful and become an obstacle.
Complex Behaviors Require a Strategy
When we talk about complex systems, such as those composed of multiple interconnected components or services, an intriguing phenomenon arises: emergent behaviors. These are behaviors that you can't predict just by looking at each piece of the system in isolation. They only become apparent when all the parts start interacting, much like an orchestra where harmony exists only with the collective contribution of all the instruments.
Here lies the challenge with unit tests: they are like microscopes, excellent for examining each instrument in that orchestra in detail, ensuring that each one is tuned and in perfect condition. However, as important as this verification is, it doesn't tell us everything. A unit test will tell you if the violin is in tune, but not how it sounds in harmony with the cellos, flutes, and trumpets.
Therefore, while unit tests are crucial for ensuring the quality of each individual component, they lack the ability to reveal how these components behave when they all come together. It is in this intersection, in this interaction, that unexpected behaviors can emerge—those that weren't intentionally programmed but arise from the complex web of relationships between the system's parts. These are often the source of complex, hard-to-trace bugs precisely because they were neither expected nor easily predictable from examining the individual components.
To capture and understand these emergent behaviors, we need a more comprehensive testing approach that goes beyond unit tests. We need tests that look at the system as a whole, considering the interactions and flows between all its components. Only then can we have a complete view of the system, including the unexpected behaviors that only surface when everything is working together.
The greatest fallacy, therefore, is the belief that passing unit tests is synonymous with a well-designed and functional system. This narrow view can steer us away from the true purpose of testing, which is to ensure not only that the code does what it is supposed to do at the most granular level but also that the system as a whole behaves correctly and predictably in all situations.
Are Unit Tests Supreme in Microservices?
I currently believe that unit tests are part of a broader testing strategy that we can adopt when working with microservices to validate domain behaviors and detect regressions that could cause significant issues—potentially major headaches for an organization. They are the foundation of the testing pyramid, but they are not the only strategy we can adopt.
However, as we focus on the orchestration between microservices, we recognize the limitations of unit tests. They are effective in ensuring that each component works in isolation, but they cannot show us whether the system as a whole operates in an integrated and cohesive manner. When we need to validate interactions between services, ensure that API contracts are respected, or verify that messages are being correctly transmitted through event buses, unit tests act as solo instruments in an orchestra that requires complete harmony.
This is where integration tests, contract tests, and end-to-end tests come into play. Each of these types of tests brings its own lens to examine the system, ensuring that the components not only function well on their own but also when they are part of a larger ensemble.
Integrated Tests
Integration tests, for example, are like diplomats ensuring that services speak the same language, that transactions flow smoothly across service boundaries. They are essential for uncovering where misunderstandings and communication breakdowns reside, allowing adjustments to be made before small errors escalate into major issues.
The Importance of Contract Tests
Contract tests, on the other hand, function like legal agreements, ensuring that each service understands and respects each other's expectations. They are the guarantee that even when a service is updated or modified, it will still fulfill its obligations to dependent services, preventing a cascade of unexpected failures.
The View of End-to-End Tests
And finally, end-to-end tests are the eagle’s eye view that surveys the entire system, ensuring that the complete business process, from start to finish, works as expected. These are the tests that validate the user journey, from the first click to the final outcome, ensuring that the user experience is as smooth and effective as designed.
Classes... We Need to Talk About Them and Their Behaviors
It’s essential to recognize how tests can be revealing regarding the responsibilities assigned to a class. When we encounter the need to instantiate a myriad of dependencies or set up a large number of mocks just to test a class, it’s a clear sign that this class might be carrying too heavy a burden. These situations indicate that the single responsibility principle might be neglected.
Excessive Dependencies
When a class has numerous external dependencies, it often indicates that it is doing more than it should. For example, a class that handles business logic, data persistence, and also network communication is clearly taking on too many responsibilities. This accumulation of responsibilities not only makes the class difficult to test but also to understand and maintain. Ideally, each class should have a single responsibility, focusing on a specific area of the system’s functionality.
Overloaded Methods
Similarly, methods that seem to perform too many tasks can be a sign that these responsibilities could be better distributed among several classes or functions. If a method is so complex that testing it requires a series of conditions and scenarios, it might be time to question whether this method could be broken down into smaller parts, each encapsulating a specific functionality that is easier to test in isolation.
Ignoring Feedback
Returning to the point about how tests provide feedback, it’s important to recognize that difficulty in writing or maintaining tests is itself a valuable type of feedback. When we encounter resistance while trying to test a behavior, it often indicates problems in our code’s design. Ignoring this feedback—whether out of inattention, lack of understanding, or the desire to 'just make it work'—is to miss an opportunity to improve the quality and maintainability of our system.
Another pertinent observation is when certain conditions or error scenarios within a class are notoriously difficult to test. This can indicate that the class is trying to handle too many eventualities, some of which could be more appropriately managed by other parts of the system. Difficulty in testing certain execution paths often reveals unnecessary complexity or a lack of clarity in the class’s responsibilities.
The Silence of the Tests: The Danger That Lurks Between the Lines
In addition to paying attention to what is being tested, it is equally crucial to be aware of the silence of the tests—what they are not telling us. Often, we can be misled by this silence, believing that everything is in order when, in fact, something essential may have been overlooked. Imagine that an important behavior in your system has regressed or, worse, that a critical functionality that should fail under certain conditions is now passing unnoticed by the tests. This silence can be more dangerous than a failed test because it creates a false sense of security, leaving real problems hidden beneath the surface.
When tests do not alert us to these regressions or unexpected behaviors, we have to ask ourselves: why is this happening? Could there be an area of the code where testability was compromised? Perhaps a poorly managed dependency or a complexity that was not properly isolated? This is the moment to listen closely to what the silence of the tests is telling us—or rather, what it is failing to tell us.
In the world of development, this type of problem can arise in various ways. A classic example is the 'God Class,' as described by Robert Martin, Uncle Bob, in his book 'Clean Code.' A 'God Class' is a class that has taken on so many responsibilities that it becomes a true god within the system, difficult to test and maintain. When you have a class overloaded with responsibilities, it is common for some crucial parts of the system’s behavior to be left out of the tests. This is not only a sign of poor design but also a potential source of silent bugs that can go unnoticed.
Similarly, Martin Fowler, in 'Refactoring: Improving the Design of Existing Code', highlights the importance of refactoring and simplifying design to improve testability. Fowler argues that when code is too complex to be easily tested, it is a clear sign that something needs to be reassessed. The 'smell' of bad code often manifests in the difficulty of writing adequate tests, and it is in these moments that the silence of the tests can become deafening.
Let’s apply this discussion to the scenario of a voucher microservice. A voucher system might seem simple, but it is filled with business rules that determine how, when, and by whom vouchers can be used. Each of these rules is essential to ensure that the system functions as expected, and if we fail to test them adequately, we could end up with serious problems that are not immediately visible.
Voucher Validation: The Silence That Precedes the Error
Imagine that the system needs to validate a voucher before applying it to a purchase. The rule is clear: 'the voucher is only valid if the expiration date has not passed.' Now, imagine that someone changes the code to adjust how dates are handled, but, by oversight, the test that should ensure the validity of vouchers based on different time zones is removed or silenced. This test, once a silent guardian of the system’s integrity, now says nothing. The system might start rejecting vouchers that should be valid, and no one will notice until customers begin to complain.
Discount Application: The Failure That Doesn’t Manifest
Another example is discount application. Let’s say the code was changed to allow new types of discounts, but the tests were not updated to cover all scenarios, especially those where discounts should not be applied. If the correct behavior is not tested—or if crucial tests are accidentally silenced—we could end up with discounts applied incorrectly, leading to significant financial losses. The test that once functioned as an alarm, warning of potential issues, is now a silent alarm, unable to fulfill its role.
Voucher Compatibility: The Silence That Costs Dearly
Consider again the compatibility of vouchers with other promotions. Each voucher has its own compatibility rules, and these rules need to be rigorously tested. If a test that verifies the correct application of compatibility rules is silenced, perhaps due to a poorly executed refactoring or code adjustment, the system might start allowing discount combinations that shouldn’t be permitted. This silence in the tests might seem harmless at first but could lead to real damage, such as customer complaints or even revenue loss.
How to Detect the Silence of Tests
Detecting the silence of tests requires a proactive and thorough approach to building and maintaining unit tests. The first step is to ensure that tests are written with a focus on the system’s observable behavior, meaning the outcomes and effects that the system should produce, rather than just concentrating on implementation details.
When we talk about observable behavior, we are referring to how the system behaves in response to different inputs and situations. To detect the silence of tests, it’s crucial to create test scenarios that cover not just the most common and obvious cases but also edge situations, where the system may react in unexpected ways.
For example, in a voucher microservice, you might create tests to check how the system handles an expired voucher, different time zones, or specific discount combinations. The focus here should be on how the system behaves externally, regardless of how the internal logic was implemented. If the expected behavior is not observed in critical situations, this indicates that something is wrong, even if the internal tests might be passing.
Constant Maintenance and Observation
Detecting and avoiding the silence of tests requires a combination of writing tests focused on observable behavior and the continuous practice of revisiting and updating these tests as the system evolves. It’s an ongoing cycle of observation, where tests are not just a tool to verify that the code works, but also a reflection of how the system should behave in all possible scenarios.
Therefore, when working on a system or microservice, keep the focus on observable behavior and regularly revisit your tests. This will not only prevent the silence of tests but also ensure that you maintain reliable, high-quality software over time.
Silence is actually a cry for help!
When we avoid testing certain parts of our code, it’s like ignoring that strange noise in the car engine. It might not seem like a big deal at first, but it could be a sign of something much more serious. Ignoring these signals in software development can not only lead to bugs and system failures but also make code maintenance a real nightmare.
Now, speaking of maintenance. A system with poorly tested business rules is like a car that has never been serviced. With every change or update, you hold your breath, hoping nothing goes wrong. And if something breaks? What if suddenly the system starts approving everyone for a loan, regardless of their repayment ability? The cost of fixing these problems after they have already affected users can be astronomical, both financially and reputationally.
Tests, in this scenario, transcend their basic function of code validation to become true allies in affirming that the software effectively performs what is essential to the business. Let’s talk more about that.
The Role of the Developer
For you, the developer, immersed in this meticulous process, remember that every test you write is a piece of this larger puzzle that is the software’s functionality. Your work here is not just about coding; it’s about bringing the business vision to life, about ensuring that the software not only 'works' in a technical sense but also acts as a true enabler of business objectives. But obviously, we are not alone in this; we have techniques and strategies to help us, such as BDD.
Behavior-Driven Development (BDD): The Strategy that Gives Voice to Your Tests.
Imagine a scenario where your development team is facing a challenging situation: complex business rules, tight deadlines, and a constant need to align technical work with customer expectations. How many times have you found yourself in a technical meeting where, despite all the knowledge shared, something seemed to be missing? Something like a clear vision of why we are building this, beyond just how to make it work.
This is where Behavior-Driven Development shines. It’s not just a testing technique, but a philosophy that places the expected behavior of the software at the center of everything. But how does it work in practice, and how can it actually help your team save time and build software that makes sense?
More Than Just Tests
BDD is not simply a different way to write tests. It’s an approach that begins even before the code is written. BDD invites us to explore why we are building something, identifying the expected behaviors of the software in collaboration with all stakeholders. Here, the '3 Amigos' rule comes into play—developers, testers, and business representatives come together to define what the system should do in terms of behavior.
This initial collaboration is essential. By discussing the expected behavior, everyone on the team gains a clear vision of the final purpose, making decision-making easier throughout the development process. As John Ferguson Smart puts it in BDD in Action, 'BDD is not just about how to write good tests—it’s about how to write the right software.' Think about it: how much time could be saved if everyone knew from the start what the real goal of a feature was?
The Rule of the 3 Amigos: Collaboration That Generates Value
The Rule of the 3 Amigos is a practice that embodies the essence of BDD. What does it mean to bring developers, testers, and business representatives together? It means that before any code is written, these three perspectives are aligned on what needs to be done. This not only reduces the risk of misunderstandings but also speeds up the development process because expectations are clear and well-defined.
Let’s take a practical example. Instead of a developer assuming that 'loan approval' simply needs to work, the conversation with the 3 Amigos could reveal nuances such as 'What specific criteria should be evaluated to approve a loan?' or 'How should we calculate interest rates to comply with regulatory requirements?' These questions help shape the test scenarios in a way that captures exactly what is needed for the software to succeed—not just technically, but in terms of meeting business requirements.
The Depth of BDD: Listening to Your Tests
Now, imagine you’re writing tests based on the scenarios defined by BDD. Each test scenario reflects a conversation, a discovery of what really matters to the business. This completely changes the dynamics of how the code is developed and tested. When well implemented, BDD ensures that the code not only passes the tests but that these tests are aligned with the expectations of all involved.
This practice saves time. Think about the time that could be lost developing a feature that, in the end, does not achieve the expected goal. With BDD, you avoid these problems because every step of the development process is guided by scenarios that have been discussed and validated by the entire team from the outset.
John Ferguson Smart emphasizes in BDD in Action that 'the beauty of BDD lies in the simplicity and clarity of the tests—they are an expression of the desired behavior, not an isolated technical check.' In other words, by listening to your tests—literally, observing what they say about the system’s behavior—you are actually listening to the business needs. This transforms tests into a strategic tool, not just a technical hurdle to overcome.
Putting BDD into Practice: A Path to Success
Now that we understand the value of BDD, how can we effectively put it into practice? Here are some steps:
Start with the Rule of the 3 Amigos: Whenever a new feature is proposed, gather the 3 Amigos—developer, tester, and business representative. Work together to define the expected behaviors and acceptance criteria.
Write Test Scenarios in Natural Language: Use tools like Cucumber or JBehave to write test scenarios that are understandable by everyone. This not only facilitates communication but also ensures that the code is guided by clear and well-defined behaviors.
Keep the Focus on Behavior, Not Implementation: When writing tests, focus on what the system should do, not how it will be implemented. This leaves room for technical flexibility while maintaining alignment with business needs.
Review and Refine Constantly: BDD is an iterative process. As the project progresses, revisit the test scenarios, discuss them with the 3 Amigos, and refine the behaviors to ensure that development continues to align with business objectives.
Asking the Right Questions at the Right Time
How many times, when starting the development of a new feature, have you or your team had doubts about what really needed to be delivered? Maybe the documentation wasn’t clear, or maybe the requirements changed without everyone being properly informed. These situations, unfortunately, are more common than we’d like to admit, and often lead to development that needs to be redone or adjusted, wasting valuable time and resources. Behavior-Driven Development (BDD) offers a solution to this problem by guiding teams to ask the right questions at the right time.
The Importance of Questions at the Beginning of Development
In software development, the most impactful moment is the beginning of the process. The decisions made at this stage can define the success or failure of the project. This is where BDD shines, encouraging the formulation of fundamental questions before any code is written.
BDD promotes an approach centered on questions that investigate the expected behavior of the system. Instead of asking 'How are we going to implement this feature?' BDD directs us to ask 'Why is this feature necessary?' and 'What does this feature need to do to meet business expectations?' These questions are essential to ensure that everyone on the team understands the true purpose of the feature.
As John Ferguson mentions in BDD in Action: 'When you start with behavior, technique becomes an afterthought. Implementation naturally follows when you understand what needs to be achieved.' This means that by focusing on the why and what from the beginning, the team is in a much better position to make technical decisions that support the real needs of the business.
Common Failures: The Rush to Code Without Full Understanding
Many engineering teams fail to dedicate enough time to these initial questions. The pressure to deliver quickly, the false sense that starting to code as soon as possible is the most efficient path, and the lack of clear communication among stakeholders are contributing factors.
The result? Features that are technically correct but don’t solve the right problem. Or worse, features that need to be reworked because new information came to light during development—information that could have been identified from the start with the right questions.
A classic example is when a team begins developing a new payment feature in an e-commerce system without fully understanding the business rules behind different payment methods. They implement the feature effectively from a technical standpoint, but when the system goes live, it turns out that it doesn’t meet the regulatory requirements for credit card transactions in certain countries. This failure is not due to a lack of technical ability but rather a lack of clarity and alignment in what should have been asked and answered before development began.
How BDD Promotes the Right Questions
BDD structures the development process to ensure that these crucial questions are asked. This is achieved through the creation of test scenarios in natural language, which are discussed and validated by all involved—the famous '3 Amigos' we mentioned earlier.
When writing BDD scenarios, each one should reflect a real situation that the software will need to handle. For example, in a financing system, the scenarios are not limited to 'the system should approve the loan if the credit score is sufficient.' They go further, asking: 'What happens if the customer has a sufficient credit score but existing debts that need to be considered?' or 'How should the system react if there’s an error in validating customer data during the loan application?'
These questions are not trivial. They force the team to consider all the variables that can impact the system’s behavior, leading to a deeper understanding of what needs to be built. Additionally, by documenting these issues in the test scenarios, they are recorded and visible to everyone on the team, facilitating communication and alignment throughout the project.
Where Engineering Teams Are Failing
Despite the clear benefits, many engineering teams still fail to implement BDD effectively. A common mistake is treating BDD as just another layer of automated testing, without taking the opportunity to explore and document the expected behavior of the system.
Another problem is the lack of involvement of all stakeholders. BDD only works when there is collaboration between developers, testers, and business representatives. If one of these groups is not involved in defining the behavior scenarios, the process loses much of its effectiveness.
Some teams underestimate the importance of time dedicated to these initial discussions. The thought that 'we don’t have time for so many meetings' may seem valid in a deadline-driven environment, but the reality is that the time invested at the beginning is recovered many times over during the project’s lifecycle. When the right questions are asked at the right time, misunderstandings are minimized, and the need for rework is drastically reduced. BDD guides teams to ask the right questions at the right time, ensuring that what is developed truly meets the needs of the business.
Therefore, the next time your team is about to start a new feature, stop and reflect: Are we really understanding what we need to build? Are we asking the right questions? If the answer isn’t clear, it may be time to reassess the process and allow BDD to guide you to more effective development, where common failures become exceptions, and success becomes the norm.
Listen to what your tests are trying to tell you
Software testing goes far beyond a simple “pass or fail” check. It’s like an iceberg: what we see on the surface is only a fraction of what’s really going on. By listening carefully to test feedback, we can reveal deep design issues, inconsistencies in the code, and even misalignments with customer requirements.
Developing the ability to interpret these messages is essential to improving not only the quality of the code, but also the efficiency and clarity of our deliverables. Instead of treating tests as technical barriers, we should see them as valuable allies, capable of guiding us towards more robust and well-designed software. The real gain is not just in ensuring that the code works, but in understanding the insights that tests offer to refine, adjust, and evolve our solutions.