Managed vs. Unmanaged Dependencies
About this...
A way to differentiate between mutable infrastructural dependencies
In a layered architecture there are often a number of mutable, shared, external, out-of-process infrastructural dependencies. We can break these down even further into two categories: managed or unmanaged.
Managed dependencies
An external, mutable, out-of-process, shared dependency is considered managed when we:
- have full control over it because it is specific to our application AND
- interactions with it are hidden from the outside world AND
- if an external system wants to interact with it, that has to be done through your application's API
Examples of managed dependencies
- Your application database
How to test managed dependencies
The best way to test a managed dependency like an application database is to write a contract test (which is a form of an integration test).
Since the interface for your repository object defines all of the things an implementatation must be able to do, your test exercises each of the methods in the interface, confirming that each works properly.
There is absolutely no mocking in this type of test. It needs to be as real as possible.
Unmanaged dependencies
An external, mutable, out-of-process, shared dependency is considered unmanaged when we:
- don't have full control over it because part of it is not fully owned by us OR
- it is also used by other applications and changing it could potentially break other applications
Examples of unmanaged dependencies
- A message bus (used by other applications in your enterprise)
- An email-sending adapter (because it actually sends an email using an email service like Sendgrid or Mailchimp)
- A payments API like Stripe or PayPal
- Any other external API not owned by you or your team
How to test unmanaged dependencies
In a layered architecture, there are two ways we can test unmanaged dependencies:
- Use case test: Test that our application will call the command-like operation at the correct time and with the correct arguments
- Integration test: Test that we can communicate with the external, unmanaged dependency
For example, production usage of unmanaged dependencies might look like the following:
// Publishing an event onto an event bus
bus.publishEvent(event);
// Sending an email
emailService.sendEmail(email);
// Charging a credit card
stripe.createCharge(customer, { ... })
Because both tests test different aspects of the application, it's often a good idea to write both use case tests and integration tests.
Use case testing unmanaged dependencies
In use case tests, the way we verify that our application has called these dependencies at the right time and with the correct parameters, is to use a mock.
Rule: As per the CQS principle, only use mocks to assert against commands (outgoing interactions); never use mocks for queries (use stubs for that)
Consider we're writing a makeOffer
use case in a vinyl-trading application. Using a use case test to ensure that it works properly, one observable outcome/state change is to test that an email gets sent to the vinyl owner.
Because we don't want to pass in a real email-sending service that actually sends emails, we'll need a way to validate that the use case told the (unmanaged) dependency - the outgoing adapter - to send the email.
We can do this with either a mock or a spy (the difference is that a spy is a hand-coded version of a mock).
We will use both a mock and a spy in the following example:
// modules/notification/notificationService.ts
export interface INotificationService {
sendEmail (email: Email): Promise<void>;
}
// modules/trading/useCases/makeOffer/makeOffer.ts
export class MakeOffer {
constructor (
private vinylRepo: IVinylRepo,
private tradesRepo: ITradesRepo,
private notificationService: INotificationService
) {
}
async execute (request: any) {
... // Implementation here
}
}
// modules/notification/mocks/notificationsSpy.ts
import { Email, INotificationService } from "./makeOffer";
// Here, we define an `INotificationService` which can be subtituted in.
// We keep track of the emails sent and write specific methods in order
// to help us assert that we'll have called a command-like operation
// (the outcome-related behavior) when we should have.
export class NotificationsSpy implements INotificationService {
private emailsSent: Email[];
constructor () {
this.emailsSent = [];
}
// This is the behavior related to the outcome. The command.
public async sendEmail (email: Email): Promise<void> {
this.emailsSent.push(email);
}
// This is a method we add to make it easier to assert
// in our test.
getEmailsSent () {
return this.emailsSent;
}
}
// modules/trading/useCases/makeOffer/makeOffer.spec.ts
import { ITradesRepo, IVinylRepo, MakeOffer } from "./makeOffer";
import { NotificationsSpy } from "./notificationSpy";
import { createMock } from 'ts-auto-mock';
describe('makeOffer', () => {
describe(`Given a vinyl exists and is available for trade`, () => {
describe(`When a trader wants to place an offer using money`, () => {
test(`Then the offer should get created and an email should be sent to the vinyl owner`, async () => {
// Arrange
// Stub: This is used to perform a query - no assertion on this
let fakeVinylRepo = createMock<IVinylRepo>();
// Mocks: These are used to validate commands (state changes)
let mockTradesRepo = createMock<ITradesRepo>(); let notificationsSpy = new NotificationsSpy();
let makeOffer = new MakeOffer(
fakeVinylRepo,
mockTradesRepo,
notificationsSpy
);
// Act
let result = await makeOffer.execute({
vinylId: '123',
tradeType: 'money',
amountInCents: 100 * 35
});
// Assert
expect(mockTradesRepo.save).toHaveBeenCalled(); expect(notificationsSpy.getEmailsSent().length).toEqual(1); })
})
})
})
Some people will say "use mocks for unmanaged dependencies (external APIs, etc). Don't use mocks for managed ones (application database)". This doesn't take into consideration cases when you're using your database as event storage and it also then says that it's illegal for you to confirm that you've performed a save()
operation at the end of a use case (which, according to the following test, is part of what we should be testing).
I think a better rule is the following:
Rule: Use mocks for commands, use stubs for queries
A Google search on this principle informs me that Mark Seemann agrees.
Also, because queries can be seen as implementation details:
Rule: Do not perform assertions on queries
If we're unit testing a query (use case) and we want to ensure that it returns the correct data, we should only assert on the result.
Integration testing unmanaged dependencies
Since other types of tests cover the contract and behavior, all we care about is that our application can communicate with the dependency. For example, if we're using Stripe's payment API, we merely want to ensure that our application can connect to Stripe.
This is substantially trickier. As Matthias Noback suggests in “Advanced Web Application Architecture”, you can either:
- Write the test against the real service
- (Ideal) Write the test against a sandbox environment the third party offers
- Write the test against a fake server that you run
- Write the test against a fake or mock HTTP client offered by the client library that you use
- Write the test against a fake or mock of the HTTP client interface that you use
Further reading
Join 15000+ value-creating Software Essentialists getting actionable advice on how to master what matters each week. 🖖