Scenario
Your system sends an activation link for users.
When the user accepts the invitation, your system fetches the user from a third-party service by their email and does some work.
async acceptInvitation(email: string): Promise<UserDTO> {
const user = await this.userLoader.findByEmail(email);
if (!user) {
throw new UserNotFoundError(email);
}
//Do something
}
and the UserLoader is like:
class UserLoader {
public findByEmail(email: string) {
return thirdPartyClient.fetch(email);
}
}
and the test:
it("should return not found when user doesn not exists", async() => {
jest.spyOn(userLoader, 'findByEmail').mockResolvedValue(null);
await expect(
userService.acceptInvitation('e@mail.com')
).rejects.toBeInstanceOf(UserNotFoundError);
})
So far, everything looks fine ✅
A Small Change ... A Big Problem ❌
Now your system grows. You introduce your own database. You introduce findById.
class UserLoader {
public findByEmail(email: string) {
return thirdPartyClient.fetch(email);
}
public findById(id: string) {
return dbClient.find(id)
}
}
Later, acceptInvitation changes its input from email to id:
async acceptInvitation(id: string): Promise<UserDTO> {
//const user = await this.userLoader.findByEmail(email);
const user = await this.userLoader.findById(id);
if (!user) {
throw new UserNotFoundError(id);
}
//Do something
}
Now the test must change too:
it("should return not found when user doesn not exists", async() => {
//jest.spyOn(userLoader, 'findByEmail').mockResolvedValue(null);
jest.spyOn(userLoader, 'findById').mockResolvedValue(null);
await expect(
userService.acceptInvitation('user-UUID')
).rejects.toBeInstanceOf(UserNotFoundError);
})
This is a Fragile Test ⚠️
A fragile test is a test that breaks whenever the implementation changes, even though the behavior stays the same.
The behavior is still: "If the user doesn't exist, reject invitation"
This is a test that depends on implementation, not behavior. This means our test depends on HOW things are done, not on WHAT the system does. But how can we refactor it to make sure our test depends on behavior?
How Abstraction Helps 🧠
Abstraction focuses on simplicity. Abstraction focuses on What should be done(Behavior) instead of How it is done(Implementation)?
So when we use the findByEmail, we are explicitly saying Find the user this specific way.
But what if instead we say: loadUser. This means, I don't care how you want to load the user - just load it. This is the Abstraction. Abstraction and encapsulation go hand in hand, helping us hide complexity.
How Encapsulation Helps 🧠
Encapsulation focuses on hiding complexity. The old example is when you are driving a car. You don't care about how fuel is burned. You only care that the car moves.
This is how components (modules, classes, etc.) should be designed.
Only a simple public API should be visible to other services, while complexity stays inside the component. That complexity(private parts) should be tested through the public interface (behavior).
Abstraction and Encapsulation help us to use fewer Mocks. With this approach we only need to mock the public interface.
Refactoring to Behavioral Design 🛠️
Step 1: Define a UserIdentifier
interface UserIdentifier {
id?: string,
email?: string
}
Now the service input becomes more abstract:
async acceptInvitation(userIdentifier: UserIdentifier)
Step 2: Introduce the loadUser
class UserLoader {
public loadUser(userId: UserIdentifier) {
// Nothing to do yet
}
public findByEmail(email: string) {
// .....
}
public findById(id: string) {
// .....
}
}
Step 3: Hide complexity and Expose Simplicity.
When we hide complexity, we prevent infrastructure implementation details from leaking into the business layer. This allows us to change infrastructure freely, without mocking it in our tests.
let's make findByEmail and findById as private (hide complexity).
class UserLoader {
public async loadUser(userIdentifier: UserIdentifier) {
if (userIdentifier.id) {
return this.findById(userIdentifier.id)
} else if (userIdentifier.email) {
return this.findByEmail(userIdentifier.email)
}
}
private findByEmail(email: string) {
return thirdPartyClient.fetchByEmail(email);
}
private findById(id: string) {
return mongoClient.findById(id);
}
}
Step 4: Use loadUser in the Service
async acceptInvitation(userIdentifier: UserIdentifier): Promise<UserDTO> {
//const user = await this.userLoader.findByEmail(email);
//const user = await this.userLoader.findById(id);
const user = await this.userLoader.loadUser(userIdentifier);
if (!user) {
throw new UserNotFoundError(userIdentifier);
}
//Do something
}
Step 5: Update Tests
it("should return not found when user id doesn not exists", async() => {
jest.spyOn(userLoader, 'loadUser').mockResolvedValue(null);
await expect(
userService.acceptInvitation({id: 'user-UUID'})
).rejects.toBeInstanceOf(UserNotFoundError);
})
it("should return not found when user email doesn not exists", async() => {
jest.spyOn(userLoader, 'loadUser').mockResolvedValue(null);
await expect(
userService.acceptInvitation({email: 'e@mail.com'})
).rejects.toBeInstanceOf(UserNotFoundError);
})
✨Now, the test depends on behavior, not implementation.
New Changes Come 🛠️
Later, you decide to load the user's name. Your service(business) and your tests wouldn't change. Only you need to extend your test case, not modify it.
interface UserIdentifier {
id?: string,
email?: string,
name?: string // add new identifier
}
class UserLoader {
public async loadUser(userIdentifier: UserIdentifier) {
if (userIdentifier.id) {
return this.findById(userIdentifier.id)
} else if (userIdentifier.email) {
return this.findByEmail(userIdentifier.email)
} else if (userIdentifier.name)(
return this.findByName(userIdentifier.name)
)
}
private findByEmail(email: string) {
return thirdPartyClient.fetchByEmail(email);
}
private findById(id: string) {
return mongoClient.findById(id);
}
private findByName(name: string) {
return postgresClient.findByName(name)
}
}
and the tests are extended, not modified
it("rejects invitation if user id doesn not exist", async() => {
jest.spyOn(userLoader, 'loadUser').mockResolvedValue(null);
await expect(
userService.acceptInvitation({id: 'user-UUID'})
).rejects.toBeInstanceOf(UserNotFoundError);
})
it("rejects invitation if user email doesn not exists", async() => {
jest.spyOn(userLoader, 'loadUser').mockResolvedValue(null);
await expect(
userService.acceptInvitation({id: 'e@mail.com'})
).rejects.toBeInstanceOf(UserNotFoundError);
it("rejects invitation if user name doesn not exists", async() => {
jest.spyOn(userLoader, 'loadUser').mockResolvedValue(null);
await expect(
userService.acceptInvitation({name: 'user-name'})
).rejects.toBeInstanceOf(UserNotFoundError);
})
Conclusion
When tests break after a simple refactor, the problem is usually not the tests themselves — it’s the design behind them.
By applying abstraction and encapsulation, we can decouple business logic from implementation details, reduce unnecessary mocking, and write tests that focus on what the system does, not how it does it. The result is a test suite that is more stable, easier to maintain, and far less painful to evolve.
Good design doesn’t just make code cleaner — it makes testing easier.
Let’s Collaborate 🤝
This is not the only way to design testable code, and there are always trade-offs.
If you’ve faced similar issues, have a different approach, or see something that could be improved in this example, I’d love to hear your thoughts. Feel free to share your experience, suggest alternatives, or challenge the ideas in this post.
If you’re interested in collaborating on engineering topics such as testing, design simplicity, or writing more maintainable code, let’s connect. Thoughtful discussion is how we all get better.
About the Author
Elias Mohammadi — Software Engineer. Believes software matters.
- 🔗 LinkedIn: https://linkedin.com/in/eliasmohammadi
- 💻 GitHub: https://github.com/eliasmohammadi
Top comments (0)