DEV Community

Cover image for Tests Should Depend on Behavior, Not Implementation - But How?
elias mohammadi
elias mohammadi

Posted on

Tests Should Depend on Behavior, Not Implementation - But How?

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
}
Enter fullscreen mode Exit fullscreen mode

and the UserLoader is like:

class UserLoader {
    public findByEmail(email: string) {
        return thirdPartyClient.fetch(email);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);

})
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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

}
Enter fullscreen mode Exit fullscreen mode

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);

})
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Now the service input becomes more abstract:

async acceptInvitation(userIdentifier: UserIdentifier)
Enter fullscreen mode Exit fullscreen mode

Step 2: Introduce the loadUser

class UserLoader {

 public loadUser(userId: UserIdentifier) {
  // Nothing to do yet
 }

 public findByEmail(email: string) {
     // .....
 }
 public findById(id: string) {
     // .....
 }
}
Enter fullscreen mode Exit fullscreen mode

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);
 }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
})
Enter fullscreen mode Exit fullscreen mode

✨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
}
Enter fullscreen mode Exit fullscreen mode
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) 
 }
}
Enter fullscreen mode Exit fullscreen mode

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);

})
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)