My Journey to Master Testing: A Reflective Guide for Developers

An Introduction to Functional Programming Through Lambda Calculus

Ever since I embarked on my software development career, one fundamental truth has stood out - testing is a non-negotiable part of the process. I want to share what I've learned about unit testing - not as a textbook but as a fellow traveller sharing notes from the journey.

Why Testing Matters to Me

My code is a product of my intellect, the material manifestation of abstract concepts formed in my mind. Testing, for me, is the bridge that confirms if the reality of my code aligns with the vision in my mind. It checks if my code behaves as expected and minimizes the risk of introducing bugs.

The first type of testing I was introduced to was Unit Testing. It focuses on verifying individual pieces of code, such as functions or methods, in isolation. This isolated testing helped me pinpoint the exact location of a bug when a test failed. This was like having a well-labeled map for my code.

Exploring the Landscape of Testing

I discovered that there was more to it than just unit tests. I learned about other types of testing, each with its unique strengths and challenges.

Here's what I found during my research:

Unit Testing

Unit tests are the foundational building blocks of any testing strategy. As the name suggests, unit tests focus on the smallest testable parts of my code, such as functions or methods. The goal is to validate each piece of the code in isolation, which means any dependencies need to be mocked or stubbed.

Pros:

  • Pinpoint precision: When a unit test fails, I know exactly where the bug is.
  • Fast feedback: Because they're small and isolated, unit tests run quickly, providing instant feedback in the problem area.

Cons:

  • Limited scope: While unit tests are great for catching bugs in individual components, they can't detect issues with how components interact.

Example of a Unit Test in TypeScript using Jest:

describe('add', () => {
  it('adds two numbers together', () => {
    expect(add(1, 2)).toBe(3)
  })
})
describe('add', () => {
  it('adds two numbers together', () => {
    expect(add(1, 2)).toBe(3)
  })
})

Integration Testing

When I started working with larger codebases and more complex systems, I realized that unit tests were severely lacking. They could tell me if individual components worked as expected, but they couldn't tell me if those components worked together. That's where integration testing came in.

Pros:

  • Detects interaction bugs: Integration tests can catch bugs that unit tests miss, especially those that occur when different parts of the system interact.

Cons:

  • Slower and more complex: Integration tests are slower to run and can be more complex to set up and maintain because they involve multiple parts of the system.

Example of an Integration Test in TypeScript using Jest:

describe('UserController', () => {
  it('creates a new user', async () => {
    const userController = new UserController()
    const newUser = await userController.create({
      name: 'Alice',
      email: 'alice@wonderland.com'
    })
 
    expect(newUser.name).toBe('Alice')
    expect(newUser.email).toBe('alice@wonderland.com')
  })
})
describe('UserController', () => {
  it('creates a new user', async () => {
    const userController = new UserController()
    const newUser = await userController.create({
      name: 'Alice',
      email: 'alice@wonderland.com'
    })
 
    expect(newUser.name).toBe('Alice')
    expect(newUser.email).toBe('alice@wonderland.com')
  })
})

End-to-End (E2E) Testing

End-to-end tests, as the name suggests, test the entire system from start to finish. They mimic real-world user scenarios and can be thought of as a simulation of the actual user experience.

Pros:

  • Real-world scenarios: E2E tests are the closest to how the end-users will interact with the system. They can catch bugs that may slip past both unit and integration tests.

Cons:

  • Time-consuming and expensive: E2E tests are the slowest and most resource-intensive tests. They also require significant effort to write and maintain.

Example of an E2E Test using Cypress:

describe('User Registration', () => {
  it('should register a new user', () => {
    cy.visit('/register')
 
    cy.get('#name').type('Alice')
    cy.get('#email').type('alice@wonderland.com')
    cy.get('#password').type('password')
    cy.get('#register-button').click()
 
    cy.url().should('include', '/dashboard')
  })
})
describe('User Registration', () => {
  it('should register a new user', () => {
    cy.visit('/register')
 
    cy.get('#name').type('Alice')
    cy.get('#email').type('alice@wonderland.com')
    cy.get('#password').type('password')
    cy.get('#register-button').click()
 
    cy.url().should('include', '/dashboard')
  })
})

Performance Testing

Performance tests are a different breed. Instead of checking if the system works correctly, they check if the system works well. They are designed to stress the system and identify its limits and bottlenecks.

Pros:

  • Identifies bottlenecks: Performance tests help identify parts of the system that slow down under load, allowing you to optimize them as they are caught.

Cons:

  • Complex to set up and analyze: Performance tests require complex setup and analysis, and they often need to be run in an environment similar to the production environment to be meaningful.

My Principles for Writing Good Unit Tests

Throughout my journey in testing, I've come to understand that a good unit test isn't just about verifying correctness. It's also about maintainability, readability, and simplicity. Here's a deeper dive into my principles for writing good unit tests and what patterns to avoid:

1. Keep It Simple (KISS)

The KISS principle is paramount in unit testing. A test should be simple and straightforward, making it easy for anyone to understand what's being tested and why.

Bad Example:

In the following test, the logic is complicated and difficult to follow. It's hard to understand what's being tested and why.

describe('Array', () => {
  it('should handle complex operation', () => {
    const arr = [1, 2, 3]
    expect(arr.map(x => x * 2).reduce((x, y) => x + y, 0)).toBe(12)
  })
})
describe('Array', () => {
  it('should handle complex operation', () => {
    const arr = [1, 2, 3]
    expect(arr.map(x => x * 2).reduce((x, y) => x + y, 0)).toBe(12)
  })
})

Good Example:

This test is simple and easy to understand. It tests one thing, and it's clear what that thing is.

describe('Array', () => {
  it('should handle adding numbers', () => {
    const arr = [1, 2, 3]
    const result = arr.reduce((x, y) => x + y, 0)
    expect(result).toBe(6)
  })
})
describe('Array', () => {
  it('should handle adding numbers', () => {
    const arr = [1, 2, 3]
    const result = arr.reduce((x, y) => x + y, 0)
    expect(result).toBe(6)
  })
})

2. Single Responsibility (SOLID)

The single responsibility principle states that a class or function should have one and only one reason to change. This principle is part of SOLID principles, which stand for Single responsibility, Open-closed, Liskov substitution, Interface segregation, and Dependency inversion. The same applies to unit tests - a test should check only one condition.

Bad Example:

In this example, the test is trying to verify both the user's name and email. If the test fails, it's unclear whether it was the name or email verification that caused it.

describe('User', () => {
  it('should create a new user with name and email', () => {
    const user = new User('Alice', 'alice@wonderland.com')
    expect(user.name).toBe('Alice')
    expect(user.email).toBe('alice@wonderland.com')
  })
})
describe('User', () => {
  it('should create a new user with name and email', () => {
    const user = new User('Alice', 'alice@wonderland.com')
    expect(user.name).toBe('Alice')
    expect(user.email).toBe('alice@wonderland.com')
  })
})

Good Example:

Here, we've split the test into separate tests for each condition. If one of these fails, it's immediately clear what the problem is.

describe('Order', () => {
  it('should add total items', () => {
    const order = new Order()
    order.addItem('Apple', 2)
    order.addItem('Orange', 3)
    expect(order.totalItems()).toBe(5)
  })
 
  it('should contain Apple', () => {
    const order = new Order()
    order.addItem('Apple', 2)
    expect(order.hasItem('Apple')).toBe(true)
  })
 
  it('should contain Orange', () => {
    const order = new Order()
    order.addItem('Orange', 3)
    expect(order.hasItem('Orange')).toBe(true)
  })
})
describe('Order', () => {
  it('should add total items', () => {
    const order = new Order()
    order.addItem('Apple', 2)
    order.addItem('Orange', 3)
    expect(order.totalItems()).toBe(5)
  })
 
  it('should contain Apple', () => {
    const order = new Order()
    order.addItem('Apple', 2)
    expect(order.hasItem('Apple')).toBe(true)
  })
 
  it('should contain Orange', () => {
    const order = new Order()
    order.addItem('Orange', 3)
    expect(order.hasItem('Orange')).toBe(true)
  })
})

4. Readability and Descriptiveness

A good test case should be easy to read and understand. The test description should clearly state what's being tested, and the test itself should be easy to follow.

Example:

This test is descriptive and easy to follow. Anyone reading it can understand what it's testing.

describe('Calculator', () => {
  it('should add two numbers correctly', () => {
    const calculator = new Calculator()
    expect(calculator.add(2, 3)).toBe(5)
  })
})
describe('Calculator', () => {
  it('should add two numbers correctly', () => {
    const calculator = new Calculator()
    expect(calculator.add(2, 3)).toBe(5)
  })
})

5. Deterministic Results

Tests should consistently return the same result, given the same input. In other words, tests should be deterministic. Non-deterministic tests can lead to false positives or negatives and are generally much harder to debug.

Bad Example:

Here, the test might fail or pass depending on the current time, which makes it non-deterministic.

describe('Date', () => {
  it('should get the current date', () => {
    const date = new Date()
    expect(date.getDate()).toBe(new Date().getDate())
  })
})
describe('Date', () => {
  it('should get the current date', () => {
    const date = new Date()
    expect(date.getDate()).toBe(new Date().getDate())
  })
})

Good Example:

This test is deterministic. It always returns the same result, no matter when it's run.

describe('Date', () => {
  it('should format the date correctly', () => {
    const date = new Date('2023-06-27T00:00:00')
    expect(date.toISOString()).toBe('2023-06-27T00:00:00.000Z')
  })
})
describe('Date', () => {
  it('should format the date correctly', () => {
    const date = new Date('2023-06-27T00:00:00')
    expect(date.toISOString()).toBe('2023-06-27T00:00:00.000Z')
  })
})

6. Speed

Tests should run quickly. Slow tests can become a burden and slow down development. While it's sometimes unavoidable, strive to keep your tests as lightweight as possible. Mock heavy dependencies and avoid unnecessary database calls whenever you can.

Bad Example:

This test requires a database operation, which can make it slow.

describe('User', () => {
  it('should save user to database', async () => {
    const user = new User('Alice', 'alice@wonderland.com')
    await user.save()
    const dbUser = await User.findByName('Alice')
    expect(dbUser.email).toBe('alice@wonderland.com')
  })
})
describe('User', () => {
  it('should save user to database', async () => {
    const user = new User('Alice', 'alice@wonderland.com')
    await user.save()
    const dbUser = await User.findByName('Alice')
    expect(dbUser.email).toBe('alice@wonderland.com')
  })
})

Good Example:

In this test, we've replaced the database operation with a mock. This makes the test much faster and more reliable.

describe('User', () => {
  it('should save user to database', async () => {
    const user = new User('Alice', 'alice@wonderland.com')
    const mockSave = jest.spyOn(user, 'save')
    mockSave.mockResolvedValue(true)
    await user.save()
    expect(mockSave).toHaveBeenCalled()
  })
})
describe('User', () => {
  it('should save user to database', async () => {
    const user = new User('Alice', 'alice@wonderland.com')
    const mockSave = jest.spyOn(user, 'save')
    mockSave.mockResolvedValue(true)
    await user.save()
    expect(mockSave).toHaveBeenCalled()
  })
})

The Pitfalls I Learned to Avoid

It's natural to make mistakes when we're learning something new, and I've definitely had my share when creating tests. But these mishaps have also been some of my best teachers. Here are a few pitfalls that I've learned to avoid:

  1. Testing the Wrong Thing: This seems obvious, but it can be surprisingly easy to test the wrong thing. Sometimes, I would write tests that pass, but they didn't actually test what I thought they were testing.

    How I Learned: Once, I wrote a test for a method that was supposed to remove an item from an array. The test passed, but later I realized it was because I was checking if the length of the array had decreased, rather than checking if the specific item was no longer in the array. This made me realize the importance of being very clear about what exactly I am testing.

  2. Over Mocking: Mocking is useful, but it can be easy to fall into the trap of mocking too much. If you mock every single thing, then you're not really testing how your code interacts with its dependencies.

    How I Learned: In a project, I was using mocks to simulate database responses. At one point, I found myself having mocks for every single database operation. It was during a code review when a coworker pointed out that my tests were not truly reflecting the actual interaction with the database. Since then, I've learned to KISS when mocking - use it when it helps isolate the code under test, but avoid overusing it and overdoing it.

  3. Flaky Tests: Tests that pass sometimes and fail other times are known as flaky tests. They're a nightmare to debug and can lead to a lot of wasted time.

    How I Learned: I seen a test that would only pass if it was run in the morning. It was because the test was dependent on the system time using 24 hours scale. Once I identified the issue, I was able to refactor the test to use a 12 hour scale, eliminating the flakiness. This taught me the importance of making tests deterministic.

Wrapping Up

Testing is an huge part of software development, a journey of continuous learning and growth. I've shared some experiences and insights, it's not meant to be the definitive guide to testing, but a reflection of what makes a test strategy great.



Related Articles

The Resurgence of Vinyl and Analog Photography

A personal reflection on the resurgence of vinyl records and analog photography, exploring the nostalgic charm of these vintage formats and drawing parallels with the process of software development.