Minimizing Integration Tests Through Dependency Injection

Minimizing Integration Tests Through Dependency Injection

Introduction

Based on how an application has been put together, it is entirely possible for a developer to find themself in an unfortunate position where creating isolated unit tests is not frequently possible. As a result, they would resort to more integration tests and utilize heavy mocks and patches to assert correct functionality. In my experience, I have come to realize that this situation is a result of less than optimal code design. In this article, I would like to present how one can utilize dependency injection to shift from relying on integration tests to more isolated unit tests instead. We will go through an example, written in typescript, that starts off in the situation I have described that would then be refactored into a more modular piece of software that is easily testable.

Example

import { average } from "./utils";
import examHttpRepository from "./student-repository";

const calculateAverageGrade = (exams: Exam[]) => {
  const grades = exams.map(exam => exam.grade);
  return average(grades);
}

const getStudentAverageGrade = async (studentId: string) => {
  // This will get exams via an HTTP call. This makes our function impure
  const exams = await examHttpRepository.getByStudentId(studentId);
  return calculateAverageGrade(exams);
}

export default getStudentAverageGrade

Let's focus on the getStudentAverageGrade function in the code snippet above. The function is quite simple and has an intuitive signature. It takes in a student ID and returns the student's average grade. The implementation uses a repository to retrieve an array of exams which is then transformed into the student's average grade. Easy peasy. This type of code is rather typical in most JavaScript projects.

Although common, this pattern is not necessarily the best and may present challenges in maintaining a modular and testable codebase. In particular, the presence of the examHttpRepository inside getStudentAverageGrade introduces side-effects and a form of coupling within getStudentAverageGrade. Let's demonstrate these difficulties by writing out what this current function's test would look like.

import nock from "nock";
import getStudentAverageGrade from "./get-student-average-grade";

describe("getStudentAverageGrade", () => {
  it("should get a student's average grade", async () => {
    const mockExams = [
      { name: 'math', grade: 90 },
      { name: 'history', grade: 80 }
    ];

    // intercept HTTP request to to endpoint and return mock
    nock('https://api.mysite.com')
      .get('/exams')
      .query({ studentid: 'abc' })
      .reply(200, { exams: mockExams });

    const averageGrade = await getStudentAverageGrade('abc');
    expect(averageGrade).toBe(85);
  });
});

This test by definition is an integration test since getStudentAverageGrade has an integration dependency with the execution environment. We had to override Node's HTTP functionality to return our mock list of exams to accommodate examHttpRepository.getByStudentId(:studentID) in this test.

At this point, you might be fairly asking yourself "Meh, this works. What's the big deal with having this type of testing?". The biggest disadvantage is that the test is slightly implementation aware. It knows that it must provide a certain environment due to the underlying HTTP implementation detail. If we decided to change the protocol or the location of the exam resource, the test must be changed accordingly as well. In other words, our test would be coupled to the "how" rather than the "what" of the function.

Dependency Injection To The Rescue

This is where dependency injection comes into play. Rather than explicitly coupling the getStudentAverageGrade function to the examHttpRepository directly, we can instead expect the repository to be "injected" instead. This is just a fancy way of saying that the repository will be passed in as a parameter making the getStudentAverageGrade function coupled to the interface of the ExamRepository rather than the concretion. Take a look at the following amendments.

import { average } from "./utils";
import { IExamRepository } from "./student-repository";

const calculateAverageGrade = (exams: Exam[]) => {
  const grades = exams.map(exam => exam.grade);
  return average(grades);
}

const getStudentAverageGrade = (examRepository: IExamRepository, studentId: string) => {
  const exams = examRepository.getByStudentId(studentId);
  return calculateAverageGrade(exams);
}

export default getStudentAverageGrade

Although this is a minor change to the previous implementation, it presents huge advantages to the modularity and testability of the getStudentAverageGrade function.

import getStudentAverageGrade from "./get-student-average-grade";

describe("getStudentAverageGrade", () => {
  it("should get a student's average grade", async () => { 
    const mockExams = [
      { name: 'math', grade: 90 },
      { name: 'history', grade: 80 }
    ];

    const mockExamRepo: IExamRepository = {
      getByStudentId: (id: string) => Promise.resolve(mockExams)
    }

    const averageGrade = await getStudentAverageGrade(mockExamRepo, 'abc');
    expect(averageGrade).toBe(85);
  });
});

This test is no longer coupled to the getStudentAverageGrade nor is it implementation aware anymore. This enables it to be driven by the function's public API and be explicitly converted into a fully isolated unit test. As for the point on modularity, getStudentAverageGrade is now able to use alternative variants of the exam repository that abide by the IExamRepository interface and not care about the implementation details behind those variants. It shouldn't matter if the repository uses HTTP, web sockets, or the browser's local storage. The test would still pass.

You can take this example one step further. I personally would like to preserve the initial intuitive signature of getStudentAverageGrade such that it still takes in a student ID only and returns back the average grade. We can do so by creating a higher-order function, a factory, that would return our desired signature.

import { average } from "./utils";
import { IExamRepository, examRepositoryHttp } from "./student-repository";

interface IGetStudentAverageGrade {
  (studentID: string) => number;
}

const calculateAverageGrade = (exams: Exam[]) => {
  const grades = exams.map(exam => exam.grade);
  return average(grades);
}

// This is the higher-order function
const makeGetStudentAverageGrade = (examRepository: IExamRepository) => (
  studentId: string
) => {
  const exams = examRepository.getByStudentId(studentId);
  return calculateAverageGrade(exams);
}

export default makeGetStudentAverageGrade

// Generally the injection would occur in a separate place. for example:
// const getStudentAverageGrade = makeGetStudentAverageGrade(examRepositoryHttp);

With this setup, you are able to inject the variant of the examRepository that you need only once upon booting the application. This factory method would return a version of the getStudentAverageGrade function with the chosen repository variant preloaded in. We have preserved the simple contract that we started off with, (studentId: string) => number, increased code modularity, and simplified testing as you will see below.

import makeGetStudentAverageGrade from "./get-student-average-grade";

describe("getStudentAverageGrade", () => {
  it("should get a student's average grade", async () => { 
    const mockExams = [
      { name: 'math', grade: 90 },
      { name: 'history', grade: 80 }
    ];

    const mockExamRepo: IExamRepository = {
      getByStudentId: (id: string) => Promise.resolve(mockExams)
    }

    const getStudentAverageGrade = makeGetStudentAverageGrade(mockExamRepo);
    const averageGrade = await getStudentAverageGrade('abc');
    expect(averageGrade).toBe(85);
  });
});

Pretty neat!

Are Integration Tests Necessary?

In short, yes. Integration tests definitely provide value to the code base. But if we take a look at the example we went through, an integration test is not necessary to test the getStudentAverageGrade function. Thinking critically about this, it's only our HTTP variant of the exam repository, not the mock variant, that produces a side-effect. It's only this variant that should be tested with integration test techniques. This means that integration test coverages can be minimized to the smallest side-effect producing units within the application.

Conclusion

I hope this example was able to demonstrate that it is highly possible to reduce the number of integration tests by amending your application's architecture and design. By promoting dependency injection, you enable your codebase to be more modular which inherently increases the number of unit tests as well. There is nothing wrong with having integration tests in your application as they are still valuable. But a high number of them may be an indicator that an improvement could be made to the software.