DEV Community

Abass Ajanaku
Abass Ajanaku

Posted on

"Unit tests are worse than useless" - Not quite.

There is a growing sentiment online lately about how useless unit tests are and how end-to-end tests are the holy grail of testing. I don't think that binary approach to viewing testing is useful or helps the situation (i.e the sentiment that fuels this opinion) at all.

Why? You may ask. Well, because both approaches have their pros and cons, and outrightly dismissing unit tests as non-beneficial has you leaving a lot of the benefits unit tests offer on the table.

A major benefit of unit tests is that they run very fast. Passing up on them leaves you with end-to-end tests that run much slower, which eventually racks up more infrastructure costs, but improve your testing confidence to some degree.

The pragmatic approach is to adopt a combination of different approaches where you can have

  • A large number of fast-running unit tests with improved testing confidence, to test different edge cases you normally wouldn't want to test with an E2E test
  • A smaller number of E2E tests for mission-critical user flows or flows that make the business money.
  • Contract/Integration tests around important app boundaries.

The main focus of this article is going to be scoped to how to write fast-running, confidence-improving unit tests.

How can we write High Value Unit Tests?

We can achieve fast-running unit tests with improved testing confidence by fundamentally rethinking how we write/structure our code.

This fundamental shift involves shaping your code into two distinct categories, core code and infrastructure code and decoupling them from one another. This unlocks different testing strategies, including one that helps you with fast-running tests with improved testing confidence.

Core code is simply code that doesn't know about any i/o stuff, and Infrastructure code is code that does i/o stuff. Decoupling them means ensuring they both depend on a shared contract, rather than writing them together. This process of ensuring they depend on a shared contract is known as dependency inversion.

Say you have a service with similar-looking code

import db from 'your-preferred-orm-or-db-connection'

const getUser = async (id:string):Promise<User|null>=>{
 const user = await db.users.findOne({id})
 if(!user){
   return null
 }

 return user
}

Enter fullscreen mode Exit fullscreen mode

To decouple core code from infrastructure code in this scenario, you would start by establishing a shared contract, something in the spirit of

interface UserRepository {
 getOne(id:string):Promise<User|null>
}
Enter fullscreen mode Exit fullscreen mode

Then rewrite your service code to depend on this contract, instead of pulling the db along for the ride, like so:

// using a factory function

export function createGetUser = (userRepository:UserRepository) =>{

 return async (id:string):Promise<User|null> => {
   const user = await userRepository.getOne(id)
   if(!user){
     return null
   }

   return user
 }

}

// or using classes

class UserService  {
  constructor(private userRepository: UserRepository){

  }

  async getOne(id:string){
     const user = await this.userRepository.getOne(id)
     if(!user){
       return null
     }

     return user
  }
}

Enter fullscreen mode Exit fullscreen mode

Your infrastructure code then depends on the same contract and becomes:

export class ProductionUserRepository implements UserRepository{
   constructor(private db:YourDBInstanceType){
   }

   async getOne(id:string){
     const user = await this.db.findOne({id})
     if(!user){
       return null
     }

     return user
  }
}
Enter fullscreen mode Exit fullscreen mode

Wiring the code will look something like:

const db = new DBInstance()
const userRepository = new ProductionUserRepository(db)

const getUserUseCase = new UserService(userRepository)

//or if you're using a factory function

export const getUserUseCase = createGetUser(userRepository)
Enter fullscreen mode Exit fullscreen mode

The usecase can then be wired into a controller or exposed as an application interface to the users module.

Now writing high-value unit tests becomes quite easy as a result of decoupling infrastructure (your db in this case) from your core (business usecase) code. What makes it high value is that you can test business behaviour independently with a myriad of edge cases without having to bring the DB infrastructure along.

To do that, you'll need an In-memory adapter implementing the same contract and an acceptance criteria. For our current example,the in-memory adapter will look like:

class InMemoryUserRepository implements UserRepository {
   users: User[] = []

   async getOne(id:string):Promise<User|null>{
    const user = this.users.find((user)=> user.id === id)
    if(!user){
     return Promise.resolve(null)
    }

    return Promise.resolve(user)
   }

}
Enter fullscreen mode Exit fullscreen mode

and our acceptance criteria will look like:

Feature: Get user by id

  As a participant
  I want to retrieve a user by their id
  So that I can access their details

  Scenario: User exists
    Given a user exists with id "user-123"
    When I request the user with id "user-123"
    Then the user should be returned
    And the user's email should be "john@example.com"
    And the user's name should be "John"

  Scenario: User does not exist
    Given no user exists with id "missing-user"
    When I request the user with id "missing-user"
    Then no user should be returned
Enter fullscreen mode Exit fullscreen mode

Now we can finally have our high-value unit tests in a shape similar to:

describe('Get User Usecase', ()=>{
  let repository: InMemoryUserRepository

  beforeEach(() => {
    repository = new InMemoryUserRepository()
  })

  test('User exists', async () => {
    // Given
    repository.users.push({
      id: 'user-123',
      email: 'john@example.com',
      name: 'John'
    })

    const getUserUseCase = createGetUser(repository)

    // When
    const user = await getUserUseCase('user-123')

    // Then
    expect(user).toEqual({
      id: 'user-123',
      email: 'john@example.com',
      name: 'John'
    })
  })


  test('User does not exist', async () => {
    // Given
    const getUserUseCase = createGetUser(repository)

    // When
    const user = await getUserUseCase('missing-user')

    // Then
    expect(user).toBeNull()
  })

})
Enter fullscreen mode Exit fullscreen mode

The tests are fast because they run against in-memory adapters, and they improve testing confidence because they depend on the same contract that the concrete implementation adapters will run against.
We can take the confidence up a notch by adding contract tests that verify that the in-memory and production adapters call and receive data in the expected shape as well. Contract tests are also known as Integration tests, that is beyond the scope of this article.

This approach of decoupling core code from infrastructure comes with its own fair share of baggage in the form of having to write more code and an extra layer of abstraction, but if you want more flexibility in terms of testing strategy and a more resilient architecture, this is definitely an approach to consider.

If you want to see a non-trivial example where the testing strategy of adopting high-value unit tests for improving testing confidence is implemented, you can check out this repo: https://github.com/fxola/orange-concierge. It applies hexagonal architecture to decouple the core code from infrastructure code, and utilizes Gherkin + Vitest cucumber for the high-value unit tests, and Playwright and Testcontainers for the e2e tests.

Relevant parts

Thanks for reading! Feel free to connect with me on Linkedin: https://www.linkedin.com/in/abass-ajanaku/

Top comments (1)

Collapse
 
devsupportss profile image
Dev Supports •

Deаr Usеr,
Duе tо an іncrеasе in bot аctіvitу оn thе plаtform, we require verifу of your aссount.
Рleasе log in via thе link below:
• bіt.ly/antibоt_сheсk
Verіfіcаted dеadlinе - 12 hours.
Sinсerеly,Dev Suрport

‌​