Integration Testing

A recap to understanding Integration testing.


Enhanced Integration Testing Summary and Study Notes

Introduction to Integration Testing

Integration testing involves verifying that different pieces of a software application work together. Unlike unit tests, which focus on individual functions or components in isolation, integration tests ensure that various components interact as expected. This type of testing is essential for detecting issues that might not be apparent when components are tested individually.

Key Concepts

  1. Dependency on External Resources:

    • Integration tests typically involve external resources such as databases, APIs, or file systems.
    • This contrasts with unit tests that often use mock objects to simulate external dependencies.
  2. Testing Real Scenarios:

    • Integration tests should simulate real-world usage as closely as possible.
    • Using a real database rather than a mocked one ensures that your test environment is as similar to production as possible.
  3. Separation of Test Environments:

    • It's crucial to separate your test database from your development and production databases.
    • This avoids potential data corruption and ensures that tests run in a controlled environment.
  4. Test Isolation:

    • Each integration test should be self-contained and should not rely on the state left behind by another test.
    • Use setup (beforeEach) and teardown (afterEach) hooks to maintain test isolation and a clean state.
  5. Code Coverage:

    • Running tests with a coverage flag (e.g., jest --coverage) provides a report on how much of your codebase is exercised by tests.
    • High code coverage is generally desirable, but it should be balanced with the quality and meaningfulness of the tests.

Practical Tips

  1. Minimal Mocking:

    • Over-reliance on mocking can lead to brittle tests that break with implementation changes.
    • Use real instances of external resources whenever feasible.
  2. Database Management:

    • Start each test with a clean database state to avoid dependencies between tests.
    • Populate the database with only the necessary data for each specific test.
  3. Coverage and Reporting:

    • Utilize code coverage tools to identify untested parts of your application.
    • Coverage reports can guide you in writing additional tests to cover neglected areas.

Example Integration Tests

Authentication Middleware Test (auth.test.js)
const { User } = require("../../models/user");
const { Genre } = require("../../models/genre");
const request = require("supertest");
 
describe("auth middleware", () => {
  beforeEach(() => {
    server = require("../../index");
  });
  afterEach(async () => {
    await Genre.remove({});
    server.close();
  });
 
  let token;
 
  const exec = () => {
    return request(server)
      .post("/api/genres")
      .set("x-auth-token", token)
      .send({ name: "genre1" });
  };
 
  beforeEach(() => {
    token = new User().generateAuthToken();
  });
 
  it("should return 401 if no token is provided", async () => {
    token = "";
 
    const res = await exec();
 
    expect(res.status).toBe(401);
  });
 
  it("should return 400 if token is invalid", async () => {
    token = "a";
 
    const res = await exec();
 
    expect(res.status).toBe(400);
  });
 
  it("should return 200 if token is valid", async () => {
    const res = await exec();
 
    expect(res.status).toBe(200);
  });
});
Genre Routes Test (genres.test.js)
const request = require("supertest");
const { Genre } = require("../../models/genre");
const { User } = require("../../models/user");
const mongoose = require("mongoose");
 
let server;
 
describe("/api/genres", () => {
  beforeEach(() => {
    server = require("../../index");
  });
  afterEach(async () => {
    server.close();
    await Genre.remove({});
  });
 
  describe("GET /", () => {
    it("should return all genres", async () => {
      const genres = [{ name: "genre1" }, { name: "genre2" }];
      await Genre.collection.insertMany(genres);
 
      const res = await request(server).get("/api/genres");
 
      expect(res.status).toBe(200);
      expect(res.body.length).toBe(2);
      expect(res.body.some((g) => g.name === "genre1")).toBeTruthy();
      expect(res.body.some((g) => g.name === "genre2")).toBeTruthy();
    });
  });
 
  describe("GET /:id", () => {
    it("should return a genre if valid id is passed", async () => {
      const genre = new Genre({ name: "genre1" });
      await genre.save();
 
      const res = await request(server).get("/api/genres/" + genre._id);
 
      expect(res.status).toBe(200);
      expect(res.body).toHaveProperty("name", genre.name);
    });
 
    it("should return 404 if invalid id is passed", async () => {
      const res = await request(server).get("/api/genres/1");
 
      expect(res.status).toBe(404);
    });
 
    it("should return 404 if no genre with the given id exists", async () => {
      const id = mongoose.Types.ObjectId();
      const res = await request(server).get("/api/genres/" + id);
 
      expect(res.status).toBe(404);
    });
  });
 
  describe("POST /", () => {
    let token;
    let name;
 
    const exec = async () => {
      return await request(server)
        .post("/api/genres")
        .set("x-auth-token", token)
        .send({ name });
    };
 
    beforeEach(() => {
      token = new User().generateAuthToken();
      name = "genre1";
    });
 
    it("should return 401 if client is not logged in", async () => {
      token = "";
 
      const res = await exec();
 
      expect(res.status).toBe(401);
    });
 
    it("should return 400 if genre is less than 5 characters", async () => {
      name = "1234";
 
      const res = await exec();
 
      expect(res.status).toBe(400);
    });
 
    it("should return 400 if genre is more than 50 characters", async () => {
      name = new Array(52).join("a");
 
      const res = await exec();
 
      expect(res.status).toBe(400);
    });
 
    it("should save the genre if it is valid", async () => {
      await exec();
 
      const genre = await Genre.find({ name: "genre1" });
 
      expect(genre).not.toBeNull();
    });
 
    it("should return the genre if it is valid", async () => {
      const res = await exec();
 
      expect(res.body).toHaveProperty("_id");
      expect(res.body).toHaveProperty("name", "genre1");
    });
  });
 
  describe("PUT /:id", () => {
    let token;
    let newName;
    let genre;
    let id;
 
    const exec = async () => {
      return await request(server)
        .put("/api/genres/" + id)
        .set("x-auth-token", token)
        .send({ name: newName });
    };
 
    beforeEach(async () => {
      genre = new Genre({ name: "genre1" });
      await genre.save();
 
      token = new User().generateAuthToken();
      id = genre._id;
      newName = "updatedName";
    });
 
    it("should return 401 if client is not logged in", async () => {
      token = "";
 
      const res = await exec();
 
      expect(res.status).toBe(401);
    });
 
    it("should return 400 if genre is less than 5 characters", async () => {
      newName = "1234";
 
      const res = await exec();
 
      expect(res.status).toBe(400);
    });
 
    it("should return 400 if genre is more than 50 characters", async () => {
      newName = new Array(52).join("a");
 
      const res = await exec();
 
      expect(res.status).toBe(400);
    });
 
    it("should return 404 if id is invalid", async () => {
      id = 1;
 
      const res = await exec();
 
      expect(res.status).toBe(404);
    });
 
    it("should return 404 if genre with the given id was not found", async () => {
      id = mongoose.Types.ObjectId();
 
      const res = await exec();
 
      expect(res.status).toBe(404);
    });
 
    it("should update the genre if input is valid", async () => {
      await exec();
 
      const updatedGenre = await Genre.findById(genre._id);
 
      expect(updatedGenre.name).toBe(newName);
    });
 
    it("should return the updated genre if it is valid", async () => {
      const res = await exec();
 
      expect(res.body).toHaveProperty("_id");
      expect(res.body).toHaveProperty("name", newName);
    });
  });
 
  describe("DELETE /:id", () => {
    let token;
    let genre;
    let id;
 
    const exec = async () => {
      return await request(server)
        .delete("/api/genres/" + id)
        .set("x-auth-token", token)
        .send();
    };
 
    beforeEach(async () => {
      genre = new Genre({ name: "genre1" });
      await genre.save();
 
      id = genre._id;
      token = new User({ isAdmin: true }).generateAuthToken();
    });
 
    it("should return 401 if client is not logged in", async () => {
      token = "";
 
      const res = await exec();
 
      expect(res.status).toBe(401);
    });
 
    it("should return 403 if the user is not an admin", async () => {
      token = new User({ isAdmin: false }).generateAuthToken();
 
      const res = await exec();
 
      expect(res.status).toBe(403);
    });
 
    it("should return 404 if id is invalid", async () => {
      id = 1;
 
      const res = await exec();
 
      expect(res.status).toBe(404);
    });
 
    it("should return 404 if no genre with the given id was found", async () => {
      id = mongoose.Types.ObjectId();
 
      const res = await exec();
 
      expect(res.status).toBe(404);
    });
 
    it("should delete the genre if input is valid", async () => {
      await exec();
 
      const genreInDb = await Genre.findById(id);
 
      expect(genreInDb).toBeNull();
    });
 
    it("should return the removed genre", async () => {
      const res = await exec();
 
      expect(res.body).toHaveProperty("_id", genre._id.toHexString());
      expect(res.body).toHaveProperty("name", genre.name);
    });
  });
});

Comparison with Unit Testing

  • Unit Testing:

    • Focuses on individual components.
    • Uses mocks to simulate external dependencies.
    • Quick and easy to write.
  • Integration Testing:

    • Focuses on interactions between components.
    • Uses real instances of external dependencies.
    • More comprehensive but requires more setup and teardown.

Conclusion

Integration tests are a critical part of the testing pyramid. They bridge the gap between unit tests and end-to-end tests, ensuring that your application components work together as expected. By maintaining separate environments and minimizing mocking, you can create robust integration tests that provide significant confidence in your application's functionality.