Keeping Tests Valuable: Social Testing at the Heart of Software!
The responsibilities of a class determine whether to use solitary or sociable unit tests.
Have you ever wondered how to ensure the heart of your software beats at the right pace? In the agile and dynamic world of software development, especially in microservices architectures, we face a crucial dilemma: choosing between solitary and companionable testing. But what really differentiates one from the other? And more importantly, how do you know which approach is best for your project? Can we adopt both at different times?
In this article, we're going to talk a lot about social testing. Let's explore their origins, understand why they are considered more reliable, and how they fit perfectly when testing your software mastery. It's not just about choosing a testing technique; it's about ensuring the robustness and reliability of your system in a microservices environment as well.
But make no mistake, every coin has two sides. We will also discuss the points of attention and essential considerations when opting for sociable tests. Last but not least, we'll cover how and when to make the right choice between a sociable and a solitary test, ensuring you're equipped with the knowledge you need to make informed decisions.
If you like the content, please share and like the post! This helps and encourages me to continue bringing content in text form too!😄
The Origin and Concept
Have you ever wondered how engineers test a new car before releasing it to the market? They are not limited to testing just the engine in isolation or just the brakes in a controlled environment. Instead, they take the car on the road, testing all its parts together under real-world conditions. This approach is very similar to the concept of sociable testing in software development.
The origin of the term "social testing" is often attributed to Martin Fowler, a renowned author and software development expert. Fowler discusses the concept of sociable testing in his article titled "Unit Testing", published on his personal website.
Martin Fowler, in his article, distinguishes two main types of unit tests: solitary tests and sociable tests. He describes solitary tests as those that test a unit of code in isolation, usually using mocks to simulate interactions with other parts of the system. On the other hand, sociable tests are those that allow the unit of code under test to interact with real implementations of its dependencies. The image below taken from Fowler's article represents the differences very well:
Think of sociable testing as a party where all the guests (software components) interact with each other. Instead of talking to each guest individually in an isolated setting, you observe how they behave and interact in a social setting. This interaction reveals much more about the group dynamics and relationships between guests than isolated individual conversations.
Understanding more!
Sociable testing emerged as a response to the limitations of solitary testing. While solitary tests focus on a single unit or component of the software, isolating it from its dependencies, sociable tests take into account the interaction of this unit with other parts of the system. It's like comparing testing a car engine in a laboratory to testing the entire car on a race track.
To write a sociable test we need to keep a few things in mind:
Test Real Interactions: Instead of using mocks or stubs, a sociable test should interact with the real dependencies of the code unit being tested. This means that the classes, services or components that the unit interacts with must be the actual implementations.
Focus on System Behavior: The objective is to verify the behavior of the system as a whole, and not just the isolated functionality of a unit. This includes testing integration and communication between different units or components.
Realistic Data: Use test data that represents real usage scenarios. This helps ensure that testing accurately reflects how the system will behave in production.
Consistent Testing Environment: The environment in which tests are run should be as close as possible to the production environment.
Test Isolation: Despite being 'sociable', each test must be independent of the others. This means that running one test should not affect the state or result of another.
Automation: Social testing should be automated to facilitate frequent and consistent execution, especially in continuous integration and continuous delivery environments.
But there are also limits, so I'm going to list three things a sociable test should never do:
You Should Not Ignore State Isolation Between Tests:
Each social test must be independent of the others. This means that a test should not depend on the state left by another previous test. Ignoring state isolation can lead to inconsistent and difficult-to-replicate test results, where a test may pass or fail depending on the order in which it is run.
You Should Not Replace Real Dependencies with Mocks or Stubs:
The essence of sociable testing is to test the interaction between different parts of the system in an environment that is as close to the real thing as possible. Replacing real dependencies with mocks or stubs goes against this principle. Although in some cases it may be necessary to simulate external dependencies (such as third-party services), excessive use of mocks can turn a sociable test into a solitary test, losing the advantage of testing real interactions.
Must Not Compromise Test System Performance:
Although sociable tests are more comprehensive, they should not be so cumbersome or time-consuming that they compromise the efficiency of the development process. Tests that are excessively slow or resource-intensive can become a hindrance, especially in continuous integration and delivery environments. It is important to strike a balance between test comprehensiveness and operational efficiency.
Keeping these considerations in mind is crucial to conducting effective sociable testing that provides valuable insights into the integrated functioning of the system without compromising the agility and efficiency of the development process.
Now let's better understand the real value of these tests in our daily lives.
The Heart of Software!
Understanding the importance of testing and validating behaviors in the domain layer of software is crucial for any programmer. This layer, where business rules and logic are implemented, is composed of entities, value objects and aggregates. Each of these elements plays a vital role in the representation and functioning of the business domain. Let's dive deep into the importance of sociable testing in this layer and how it can reveal the integration and interaction between different classes.
The Domain Layer: An Overview
Imagine the domain layer as a symphony orchestra. Each musician (entity, value object, aggregate) has a specific score to play. Individually, each part may sound perfect, but the true test of the orchestra is how well all these parts harmonize when played together. Similarly, at the domain layer, it is crucial that entities and value objects not only function in isolation but also in unison.
The Challenge with Conventional Testing
Many programmers, when testing the domain layer, focus on validating each class individually. They verify that each entity and value object behaves as expected in an isolated environment. While this is important, they often lose sight of how these classes interact with each other. It's as if each musician is playing their part without listening to the others.
To make these challenges a little clearer, let's talk more about solitary tests. And to make this as realistic as possible, let's work with a concrete example focused on a critical domain class: Voucher
. This class will implement essential business rules to determine the validity of a voucher. Additionally, we will interact with another domain class, Customer
, which plays a fundamental role in applying the voucher rules.
public class Voucher {
private String code;
private LocalDate expiryDate;
private double discountPercentage;
private double minimumPurchaseAmount;
private int maximumUsage;
private int usageCount;
public Voucher(String code, LocalDate expiryDate, double discountPercentage, double minimumPurchaseAmount, int maximumUsage) {
this.code = code;
this.expiryDate = expiryDate;
this.discountPercentage = discountPercentage;
this.minimumPurchaseAmount = minimumPurchaseAmount;
this.maximumUsage = maximumUsage;
this.usageCount = 0;
}
public boolean isValidFor(Cliente cliente, double purchaseAmount) {
return isNotExpired() && isUnderUsageLimit() && meetsMinimumPurchaseAmount(purchaseAmount) && cliente.isEligibleForVoucher(this);
}
private boolean isNotExpired() {
return LocalDate.now().isBefore(expiryDate);
}
private boolean isUnderUsageLimit() {
return usageCount < maximumUsage;
}
private boolean meetsMinimumPurchaseAmount(double purchaseAmount) {
return purchaseAmount >= minimumPurchaseAmount;
}
public void redeem() {
usageCount++;
}
// Getters and Setters
}
Customer Domain Class
public class Customer {
private String name;
private boolean isPremiumMember;
public Customer(String name, boolean isPremiumMember) {
this.name = name;
this.isPremiumMember = isPremiumMember;
}
public boolean isEligibleForVoucher(Voucher voucher) {
// Business rule: only premium members can use certain vouchers
return isPremiumMember;
}
// Getters and Setters
}
Solitary Test for Voucher
Now, let's create a solitary test for the Voucher
class. In this test, we will simulate (mock) the interaction with the Customer
class, focusing only on the isolated behavior of the Voucher
.
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDate;
public class VoucherTest {
@Test
public void testVoucherValidity() {
Cliente mockCliente = mock(Cliente.class);
when(mockCliente. (any(Voucher.class))).thenReturn(true);
Voucher voucher = new Voucher("ABC123", LocalDate.now().plusDays(10), 15.0, 100.0, 5);
assertTrue(voucher.isValidFor(mockCliente, 150.0), "Voucher should be valid for the given conditions");
}
}
Test Analysis
The testVoucherValidity()
test checks whether a Voucher is considered valid under certain conditions. It creates a mock of the Customer class and configures this mock to always return true when the isEligibleForVoucher()
method is called. It then checks whether the Voucher is valid for a given customer and purchase value.
Considerable Advantage
Test Isolation: Testing isolates the Voucher class from its external dependencies, allowing you to verify Voucher's internal logic without worrying about the actual Customer implementation.
Focus on Specific Rules: It allows you to test specific rules within the Voucher class, such as validity based on expiration date, minimum purchase value and usage limit.
Limitations
Lack of Real Interaction: When using a mock Customer, the test does not verify how a real Customer interacts with the Voucher. This means that it does not test the complete behavior of the system, as it would in a production environment.
Simplified Behavior: The test assumes that any Customer is eligible for the Voucher, which oversimplifies reality. In a real-world scenario, customer eligibility may depend on several factors such as member status, purchase history, among others.
Hidden Errors in Interactions: Testing may not capture errors or unexpected behaviors that arise when Voucher and Customer actually interact. For example, if the logic for determining customer eligibility is complex or there are additional rules that affect the validity of the voucher.
I want to extend this analysis even further by going deeper into the limitations that we may encounter and would never want to let go!
The Limitation of Solitary Testing
In the above test, we are checking whether the Voucher is valid under certain conditions. However, when using a mock Customer, we miss the opportunity to test how the Voucher actually interacts with a real Customer. For example, the rule that only premium members can use certain vouchers is crucial and can have significant implications for the system's behavior. In a solitary test, this complex and critical interaction is simplified, which can lead to a false sense of security. But the problems we may encounter don’t stop there!
The limitation of solitary testing, especially at the domain layer with complex business rules, goes beyond the inability to test real interactions between classes. There are several other nuances and challenges that can arise, making these tests less effective in ensuring software robustness and reliability in real-world scenarios.
Additional Limitations of Solitary Tests
1. Failure to Capture Emerging Behaviors
Emergent Behaviors: When multiple business rules interact, behaviors may emerge that are not evident when testing each rule in isolation. Solitary testing may fail to identify these emergent behaviors, which only become apparent when the system is tested as a whole.
2. Difficulty in Testing Interdependent Business Rules
Interdependent Rules: In many domain systems, business rules do not operate in isolation; they are interdependent. A lone test that mocks a dependency may not be able to adequately validate how rules interact and affect each other.
3. Problems with Validation of Complex States
Complex States: Some domain objects can have complex internal states that are affected by multiple operations. Solitary tests may not be sufficient to verify all possible states and state transitions, especially when these states are influenced by interactions with other classes.
Challenges with Boundaries and Hidden Errors
1. Errors at Integration Borders
Integration Boundaries: The boundaries between different domain objects or between domain and infrastructure layers are critical points where errors can hide. Solitary tests, when focusing on a single class, often fail to test these boundaries effectively.
2. Failure to Detect Integration Issues
Integration Problems: Even if each class passes the tests alone, problems may exist when they are combined. For example, incompatibilities in interfaces or failures in data passing may not be detected in tests that do not consider integration.
3. Limitations on Contract Verification
Contracts between Classes: In an object-oriented system, classes often define "contracts" about how they can be used or how they interact with other classes. Solitary testing may inadequately verify compliance with these contracts, especially when the contract involves multiple parties.
What can we learn then? While solitary tests are valuable for validating the functionality of individual units of code, they have significant limitations when dealing with complex business rules and interactions between classes in the data layer.
The Importance of Sociable Tests
This is where social testing comes into play. They are like an orchestra dress rehearsal, where all the parts are played together. These tests allow entities and value objects to interact with each other, revealing how they behave in a scenario closer to the real world.
Understanding Class Integration
When we write sociable tests, we are forced to think about how different classes are integrated. This type of testing provides a more comprehensive and realistic view of the system's behavior, although it can be more complex in terms of implementation.
Let's go to the previous example. Now, let's write a test that uses real instances of these classes to verify the interaction between them.
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDate;
public class VoucherIntegrationTest {
@Test
public void testVoucherWithRealClient() {
Cliente cliente = new Cliente("John Doe", true); // Cliente real
Voucher voucher = new Voucher("ABC123", LocalDate.now().plusDays(10), 15.0, 100.0, 5);
assertTrue(voucher.isValidFor(cliente, 150.0), "Voucher should be valid for a premium member with sufficient purchase amount");
}
}
In this test, we are creating a real Customer and checking if the Voucher is valid for that customer under specific conditions. Unlike solitary testing, here we are not using mocks, but rather working with real implementations of the classes.
Advantages of Sociable Tests
Realistic Interaction: This test better reflects how the Voucher and Customer classes interact in the real world. It checks whether business rules are applied correctly when the two classes work together.
Detection of Emergent Behaviors: Social testing can reveal behaviors that only emerge when different parts of the system interact, offering a more complete view of the system's behavior.
Validation of Complex Business Rules: They are particularly useful for validating complex business rules that depend on the interaction between multiple classes.
But not everything is rosy, I could highlight this towards the end of the article, but I believe that bringing it up now will make it easier for you to see the other side of the coin about this testing strategy.
Negative Points of Sociable Tests
Implementation Complexity: Sociable tests can be more complex to implement as they require the configuration of multiple classes and their interactions.
Execution Time: They may take longer to execute, especially if the system is large and the interactions between classes are complex.
Maintainability: Maintaining maintainable tests can be more challenging, as changes to one class can affect tests that involve multiple classes.
I want to comment more on the negative points with you:
Implementation Complexity
Why It's a Negative: Sociable testing requires setting up and understanding multiple classes and their interactions. This can be challenging as it requires in-depth knowledge of the relationships and dependencies within the domain layer. Furthermore, simulating realistic interaction scenarios between entities and value objects can be complex.
How to Deal: To manage this complexity, it is helpful to adopt clear design and architectural patterns at the domain layer. Using principles such as Dependency Injection and Interface Segregation can make classes more testable and interactions clearer. Additionally, detailed documentation of business rules and interactions can help simplify test implementation.
Runtime
Why It's a Negative: Sociable tests can be slower to run because they involve initializing and interacting with multiple parts of the system. In large, complex systems, this can result in significantly longer execution time, which can affect the agility of the development process.
How to Deal: One strategy is to optimize tests to execute only the necessary interactions. This may include using focused test data and limiting the scope of testing to critical interactions. Additionally, parallel test execution and the use of efficient test environments can help reduce total execution time.
Maintenance Challenges
Why It's a Negative: Changes to a class in the domain layer can affect several sociable tests that depend on it. This can make maintaining tests more laborious, as a single change may require updating multiple tests.
How to Deal: One approach is to modularize the domain layer code so that changes to one class have minimal impact on other parts. Additionally, adopting practices such as continuous refactoring and continuous integration can help you quickly identify and correct the impacts of changes to testing.
Although sociable testing at the domain layer presents challenges in terms of the amount of time dedicated, execution time, and maintenance, these challenges can be managed with appropriate development and design practices. Furthermore, we can highlight its fundamental role in validating critical behaviors!
Depths of Complex Behaviors in Testing
Sociable tests are particularly valuable when dealing with complex behaviors at the domain layer. They can reveal problems that only arise when different parts of the system interact. For example, how does the system handle simultaneous orders from different customers? Are there risks of race conditions or data inconsistencies? Sociable testing can help identify and resolve these issues. Furthermore, we can expand further and comment on how they avoid false test scenarios.
To understand the concept of false positives and false negatives, let's use the analogy of a COVID-19 test. Imagine you take a COVID-19 test: a false positive result means the test indicates you have the virus, when in fact you don't. On the other hand, a false negative occurs when the test indicates that you are not infected, but in reality, you are. Both scenarios are problematic: the false positive can lead to unnecessary concerns and measures, while the false negative can result in a false sense of security and inadvertent spread of the virus.
Now, applying this analogy to sociable tests in programming, especially in the context of the Voucher and Customer classes, we can understand how these tests help to avoid false positives and negatives.
Avoiding False Positives
A false positive in software testing occurs when a test indicates that there is a problem (failure) when, in fact, the functionality is correct. In other words, the test erroneously signals the presence of an error.
Let's consider a scenario where a lone test could lead to a false positive:
Example of Solitary Test with False Positive: Imagine that you are testing the logic of a method in your Voucher domain entity. This method determines whether the voucher is valid or not. In solitary testing, you create a mock for a service (or class) that interacts with the Voucher.
Let's suppose you configure this mock to always return that the voucher is invalid, regardless of the actual conditions of the voucher or the applicable business rules. When you run the test, it fails, indicating that the voucher is always invalid, even in situations where, according to business rules, it should be valid. In this case, the test is producing a false positive. The error is not in the voucher validation logic, but in the test configuration. The mock was configured inappropriately, not reflecting the real complexity and conditions of the business rules of the entire flow.
Contrast with Sociable Tests: In a sociable test, the Voucher would be tested in conjunction with a real instance of the class that is a dependency. This test would check all rules and business conditions involved in validating the voucher. For example, it could check that the voucher is correctly invalidated for ineligible customers and after the expiration date. By doing so, sociable tests reduce the chance of false positives because they are testing system behavior under more realistic and complex conditions.
Minimizing False Negatives
A false negative in software testing occurs when a test fails to detect a problem that actually exists in the software. In other words, the test passes, indicating that everything is working correctly, when in fact there is a bug or failure in the system that the test should have caught. This happens a lot if we configure the mocks in a hasty way or make a mistake at some point.
Now let's go to the scenarios:
Example of Solitary Test with False Negative: Think of a solitary test for the Voucher class that checks if the voucher is invalid under certain conditions. If this test is configured with very restrictive or specific (unrealistic) conditions, it may pass (indicating that the voucher is invalid) even when, in reality, there are test scenarios in which the voucher is valid, which should cause an error in the test! For example, the test may not consider all the eligibility conditions of a real Customer, leading to a false negative, the logic error may exist but will not be accurately captured.
Contrast with Sociable Tests: In a sociable test, the Voucher would be tested in real interaction with a class, for example Customer. This comprehensive test would check all relevant conditions and business rules. For example, it could validate that the voucher is correctly accepted for customers who meet all eligibility criteria. This minimizes the occurrence of false negatives, as the test is evaluating the system's behavior in a broader context.
But why are these two scenarios more likely to occur in solitary tests?
These scenarios are particularly likely to occur in solitary testing due to the isolated nature of these tests. Let's highlight the clear differences compared to sociable tests. 👇🏼
False Positives in Solitary Tests
Excessive Isolation: Solitary tests focus on a single unit or class, isolating it from its real dependencies. They often use mocks or stubs to simulate the behavior of these dependencies. While this is useful for testing the internal logic of the unit, it can create an unrealistic scenario where complex interactions with other parts of the system are not tested.
Lack of Integration Checking: Solitary tests do not check how the unit interacts with other parts of the system. This means that even if the drive works perfectly in isolation, it may fail or behave unexpectedly when integrated with other drives or in the production environment.
Restrictive Testing Conditions: In some cases, solitary tests may be configured with very restrictive or specific conditions that do not adequately represent the actual use of the unit. This can lead to test failures even when the unit would function correctly in a broader or integrated scenario.
Inadequate Mocks: If the mocks or stubs used in solitary tests do not accurately mimic the behavior of real dependencies, this can lead to misleading test results. For example, a mock may not simulate all possible responses or states of a dependency, resulting in a false negative.
Lack of Larger System Context: Solitary tests do not consider the broader system context. A drive may fail a solitary test due to incorrect assumptions about its operating environment, while in reality, within the larger system, it would perform as expected.
In contrast, sociable tests address many of these limitations:
Real Interactions: They allow units to interact with their real dependencies, more closely reflecting the production environment, when I refer to production, I mean the real interactions taking place.
System Complexity: Social testing considers the complexity of the system as a whole, testing how different business units and rules interact and affect each other.
Broader Context: They provide a broader context, checking not only the isolated functionality of a unit, but also its operation and integration within the larger system.
Therefore, while solitary tests are valuable for focusing on specific aspects of a code unit, they are more prone to false positives and negatives due to their isolation and simplification of test conditions. Sociable tests, by addressing the interaction and integration of units within the broader context of the system, offer a more realistic and comprehensive view, reducing the likelihood of these problematic scenarios.
So should I always choose sociable tests?
Choosing between social testing and other types of testing is not a matter of "always" or "never", but rather understanding the context and specific needs of your project. While sociable tests have their value, especially at the domain layer, they are not the universal solution for all testing scenarios. Let's reflect on this a little.
Questions for Reflection
What is the Complexity of the Interaction? Ask yourself about the complexity of the interactions between the classes or components you are testing. Sociable tests are most valuable when there is a significant and complex interaction that needs to be validated.
What is the Impact on System Behavior? Consider whether the features you are testing have a critical, observable impact on the system's behavior. If so, sociable unit tests that interact with real dependencies might be the right choice.
How Critical are Features for the Business? Evaluate the importance of features for the business. In critical areas where errors can have significant consequences, sociable testing can provide the necessary testing depth.
My goal in writing this article is to bring a clearer view of how we can bring social testing into our daily lives and use them appropriately to benefit the software we are working on.
Other Testing Layers and Strategies
For other layers, such as services, use cases and other functionalities, other testing strategies and automation may be more appropriate. For example:
Services and Use Cases: In these layers, where the business logic may be less complex or more direct, tests using mocks and stubs may be sufficient to validate the expected behavior.
Test Automations: Tools like Cucumber and Pact are excellent for acceptance and contract testing, respectively. They allow you to write tests that are more focused on the behavior of the system from the user's point of view or the communication between different parts of the system.
But you may be a more advanced reader and question everything I said here. And that's great! So leave your question in the comment.
A question that may arise is the following:
Aren't we failing the resilience of a test, since we are not using mocks for the dependencies of its classes?
This is an interesting and important question. When discussing tests that interact with your real dependencies, especially at the domain layer, it is crucial to understand that we are not leaving anything out and it also does not mean abandoning good testing practices. On the contrary, even in sociable tests, it is essential to maintain resilience, clarity and determination.
Maintaining Good Practices in Sociable Testing
Resilience and Isolation: Although sociable testing involves interaction between different parts of the system, this does not mean we should ignore resilience. Each test must be able to run independently without depending on the results of other tests. This helps ensure that if a test fails, you can quickly identify the cause of the problem.
Determinism: Sociable tests must be deterministic. This means that given the same inputs and conditions, they should always produce the same result. This is crucial for the reliability of the tests. Even though sociable tests involve complex interactions, they must be designed in a way to avoid unexpected fluctuations in results.
Controlled Side Effects: In sociable tests, side effects - that is, state changes that occur as a result of interactions between classes - must be controlled and understood. This does not mean that side effects are undesirable; on the contrary, they are often an essential part of the business logic being tested. However, it is important that these side effects are predictable and consistent.
Efficiency and Performance
Runtime Optimization: Although sociable tests can be more complex, it is important to optimize them to reduce execution time, especially on large systems.
Careful Selection of Test Scenarios: Carefully choose test scenarios to cover the most critical and relevant cases, avoiding redundancies.
Clarity and Objectivity
Clear Description: Each sociable test should have a clear description of what is being tested and why. This helps other developers quickly understand the purpose of the test.
Focus on Observable Behavior: Tests should focus on validating behaviors that are meaningful and observable, both from a technical and end-user perspective.
Therefore, by implementing sociable testing at the domain layer, we are not suggesting a haphazard or uncontrolled approach. Rather, we are expanding the scope of testing to encompass more complex and realistic interactions, while maintaining discipline and good testing practices. This includes ensuring that tests are resilient, deterministic, and appropriately manage side effects.
Do classes determine what type of testing strategy we should use?
The straight answer is yes! If the software follows a clear and clean architecture, it becomes even easier to make this distinction! Look at the image below:
Looking at this diagram, you can see how it clearly segments testing responsibilities for the different parts of a system. It's as if each type of class has a manual suggesting how you should approach testing.
The Controllers are there in the external layer and, as you already know, they need to be tested in isolation. This means that you will only focus on the logic they have, ensuring that they handle requests and responses well without worrying about other parts of the system.
Likewise, Service is another area that you test in isolation. You verify that the application logic inside the service is doing exactly what it is supposed to do, without interacting with the domain or external infrastructure.
Now, when we talk about the Domain, which includes Entities, Value Objects and Sagas, the test changes shape. You are no longer looking at them in isolation; instead, you check out how they behave when they work together. These are the classes that actually hold the business rules, so you want to make sure they work well when combined.
The diagram implies that even though the focus here is on Controllers and Domain classes, you will likely have similar testing approaches for Adapters and the Repository. Adapters must be able to communicate with external systems correctly, while the Repository must interact with the database without problems.
So, the testing strategy is really defined by the role the class plays in the architecture: whether it operates independently or whether it is part of a more complex choreography with other classes.
Hold on, I'm almost done with this article!😅
I would like to answer a possible question that I had and that many may have. 👇🏼
Why include Sagas?
This topic can be a little controversial. I won't go into that much detail. But I will try to summarize my point of view as much as possible.
Sagas in software systems are transaction management mechanisms that deal with complex, long-running operations, typically involving multiple steps and that can span multiple services and systems. They are different from traditional transactions because each step may involve network calls, database operations, or other processes that are not atomic or immediate.
Why do they need sociable tests? Well, sagas are designed to orchestrate business flows that interact with multiple parts of a system. This means they are not just sending commands and waiting for events, but they are also handling failures, compensations, and ensuring that the end state of the entire business process is consistent. To test this effectively, you need to simulate the interactions with the other parts of the system.
Imagine we have a saga that needs to validate a voucher before applying a discount to an order. The sociable test for this saga would need to simulate interactions with the voucher service, but without actually touching a real database or messaging system.
public class ApplyVoucherSagaTest {
@Test
public void shouldApplyVoucherToOrder() {
given()
.saga(new ApplyVoucherSaga(voucherServiceProxy),
new ApplyVoucherSagaState(ORDER_ID, VOUCHER_CODE)).
expect().
command(new ValidateVoucher(VOUCHER_CODE, ORDER_ID)).
to(VoucherServiceChannels.voucherServiceChannel).
andGiven().
successReplyWithDiscount(DISCOUNT_AMOUNT).
expect().
command(new ApplyDiscountToOrder(ORDER_ID, DISCOUNT_AMOUNT)).
to(OrderServiceChannels.orderServiceChannel);
}
@Test
public void shouldRejectVoucherDueToValidationFailed() {
given()
.saga(new ApplyVoucherSaga(voucherServiceProxy),
new ApplyVoucherSagaState(ORDER_ID, VOUCHER_CODE)).
expect().
command(new ValidateVoucher(VOUCHER_CODE, ORDER_ID)).
to(VoucherServiceChannels.voucherServiceChannel).
andGiven().
failureReply().
expect().
command(new RejectVoucherApplication(ORDER_ID, VOUCHER_CODE)).
to(OrderServiceChannels.orderServiceChannel);
}
}
In this test, we are using proxies or stubs for real services, such as kitchenServiceProxy
. This is an object that has been configured to respond to specific commands in a predetermined way, without involving calls to an actual kitchen service. It allows the test to verify that the saga is sending the correct commands based on the test state and expected responses.1
A solitary test would not be suitable for the ApplyVoucherSagaTest
context due to the intrinsic nature of sagas and the specific purpose of this test. Why? To do so would be to focus exclusively on the internal logic of the saga, isolating it from all its external dependencies. This would mean not simulating or testing interactions with external services such as the voucher validation service or the ordering service. However, these interactions are critical to verify that the saga is fulfilling its role within the system. Without testing these interactions:
Lacks Integration Validation: You would not be able to verify that saga can communicate correctly with external services, which is essential for its functioning.
Ignored Business Flow: Part of saga's role is to manage a business flow that transcends the boundaries of a single class or component. Solitary tests cannot capture this orchestration between different components.
Reacting to External Events: Sagas often need to react to events or responses from external services. A solitary test cannot verify whether the saga responds correctly to these external stimuli.
Fault Compensation and Management: Sagas often include logic to handle faults or compensate for previous actions. Solitary tests cannot effectively simulate these failure conditions that depend on interactions with other services.
Therefore, even with simulations in use, the saga test is still usable because it tests the integration and interaction of the saga with the abstractions of the services that would be used in production. The difference is that we are isolating the test from external variables and infrastructure, which increases predictability, speed and ease when writing and executing tests. By testing sagas in a companionable way, you ensure that the complex logic they encapsulate is working as expected within the larger application ecosystem, even when that interaction is simulated by stubs or proxies.
Conclusion
The key to an effective testing strategy is understanding your project's specific needs and challenges. While sociable testing is a powerful tool at the domain layer, it is just one part of a broader arsenal of testing techniques. Combining different testing approaches, adapting them to the needs of different parts of your system, is the best way to ensure robust and reliable software.
I really appreciate you reading the article! Feel free to comment and if it was helpful, please share! 😉
Books that I consider fundamental for a testing mentality 👇🏼
Effective Software Testing: A Developer's Guide - by Mauricio Aniche
Unit Testing Principles, Practices, and Patterns - by Vladimir Khorikov
Microservices Patterns: With Examples in Java - by Chris Richardson
The purpose of proxies and stubs is to simulate these interactions in a controlled and predictable manner, allowing tests to focus on saga business logic without the unpredictable variables of a production environment. Therefore, although the saga is not talking to the actual production services, it is "socializing" with representatives of them, which still exercises its interaction logic.
This is not a contradiction, but rather an adaptation of the concept of sociable testing to a more controlled testing environment, where what matters is the validation of the logic of the saga and not the infrastructure itself.