Lab 2 - Integration Testing Tutorial: Spring Boot and Express.js


In this tutorial, I’ll guide you through integration testing for both Spring Boot (Java) and Express.js (Node.js) applications. Unlike unit tests, which focus on isolated components, integration tests verify that different parts of the application (e.g., controllers, services, and databases) work together correctly. We’ll build on the previous examples of a book management API in Spring Boot and a user management API in Express.js, but this time we’ll include a real database for testing.

We’ll use:

  • Spring Boot: H2 in-memory database with @SpringBootTest for integration testing.
  • Express.js: SQLite with better-sqlite3 for a lightweight database, and Mocha/Chai/Supertest for testing.

Overview

Integration testing ensures that the components of your application work together as expected. For a REST API, this typically means testing the full stack (controller, service, and database) by making real HTTP requests and interacting with a database. We’ll set up an in-memory database for both applications to keep the tests fast and independent of external systems.


Part 1: Spring Boot Integration Testing

Step 1: Set Up a Spring Boot Project with a Database

We’ll extend the book management API from the previous tutorial by adding a database layer using Spring Data JPA and an H2 in-memory database for testing.

  1. Add Dependencies: Open your Spring Boot project’s pom.xml and ensure the following dependencies are included (they might already be there if you followed the previous tutorial):

    <dependencies>
        <!-- Spring Web for REST API -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- Spring Data JPA for database access -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <!-- H2 Database for in-memory testing -->
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <!-- Spring Boot Test -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
  2. Configure H2 Database: In src/main/resources/application.properties, add the following to configure the H2 in-memory database:

    spring.datasource.url=jdbc:h2:mem:testdb
    spring.datasource.driverClassName=org.h2.Driver
    spring.datasource.username=sa
    spring.datasource.password=
    spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
    spring.h2.console.enabled=true
    spring.jpa.hibernate.ddl-auto=update
  3. Update the Book Model for JPA: Modify Book.java in src/main/java/com/example/demo/model to make it a JPA entity:

    package com.example.demo.model;
    
    import jakarta.persistence.Entity;
    import jakarta.persistence.GeneratedValue;
    import jakarta.persistence.GenerationType;
    import jakarta.persistence.Id;
    
    @Entity
    public class Book {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
        private String title;
        private String author;
    
        // Default constructor for JPA
        public Book() {}
    
        public Book(Long id, String title, String author) {
            this.id = id;
            this.title = title;
            this.author = author;
        }
    
        // Getters and Setters
        public Long getId() { return id; }
        public void setId(Long id) { this.id = id; }
        public String getTitle() { return title; }
        public void setTitle(String title) { this.title = title; }
        public String getAuthor() { return author; }
        public void setAuthor(String author) { this.author = author; }
    }
  4. Create a Book Repository: In src/main/java/com/example/demo/repository, create BookRepository.java:

    package com.example.demo.repository;
    
    import com.example.demo.model.Book;
    import org.springframework.data.jpa.repository.JpaRepository;
    
    public interface BookRepository extends JpaRepository<Book, Long> {
    }
  5. Update the Book Service: Modify BookService.java in src/main/java/com/example/demo/service to use the repository:

    package com.example.demo.service;
    
    import com.example.demo.model.Book;
    import com.example.demo.repository.BookRepository;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Service;
    
    import java.util.List;
    import java.util.Optional;
    
    @Service
    public class BookService {
        @Autowired
        private BookRepository bookRepository;
    
        public List<Book> getAllBooks() {
            return bookRepository.findAll();
        }
    
        public Book getBookById(Long id) {
            Optional<Book> book = bookRepository.findById(id);
            return book.orElse(null);
        }
    
        public Book saveBook(Book book) {
            return bookRepository.save(book);
        }
    }
  6. Update the Book Controller: Add a POST endpoint to BookController.java in src/main/java/com/example/demo/controller:

    package com.example.demo.controller;
    
    import com.example.demo.model.Book;
    import com.example.demo.service.BookService;
    import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.http.ResponseEntity;
    import org.springframework.web.bind.annotation.*;
    
    import java.util.List;
    
    @RestController
    @RequestMapping("/api/books")
    public class BookController {
        @Autowired
        private BookService bookService;
    
        @GetMapping
        public List<Book> getAllBooks() {
            return bookService.getAllBooks();
        }
    
        @GetMapping("/{id}")
        public ResponseEntity<Book> getBookById(@PathVariable Long id) {
            Book book = bookService.getBookById(id);
            if (book == null) {
                return ResponseEntity.notFound().build();
            }
            return ResponseEntity.ok(book);
        }
    
        @PostMapping
        public Book createBook(@RequestBody Book book) {
            return bookService.saveBook(book);
        }
    }

Step 2: Write Integration Tests for Spring Boot

We’ll use @SpringBootTest to load the full application context and @AutoConfigureMockMvc to test HTTP requests. The H2 database will be used automatically.

  1. Create an Integration Test Class: In src/test/java/com/example/demo, create BookControllerIntegrationTest.java:

    package com.example.demo;
    
    import com.example.demo.model.Book;
    import com.example.demo.repository.BookRepository;
    import com.fasterxml.jackson.databind.ObjectMapper;
    import org.junit.jupiter.api.BeforeEach;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.http.MediaType;
    import org.springframework.test.web.servlet.MockMvc;
    
    import static org.hamcrest.Matchers.hasSize;
    import static org.hamcrest.Matchers.is;
    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
    import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
    
    @SpringBootTest
    @AutoConfigureMockMvc
    public class BookControllerIntegrationTest {
    
        @Autowired
        private MockMvc mockMvc;
    
        @Autowired
        private BookRepository bookRepository;
    
        @Autowired
        private ObjectMapper objectMapper;
    
        @BeforeEach
        public void setUp() {
            bookRepository.deleteAll();
            bookRepository.save(new Book(null, "The Great Gatsby", "F. Scott Fitzgerald"));
            bookRepository.save(new Book(null, "1984", "George Orwell"));
        }
    
        @Test
        public void testGetAllBooks() throws Exception {
            mockMvc.perform(get("/api/books")
                    .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$", hasSize(2)))
                .andExpect(jsonPath("$[0].title", is("The Great Gatsby")))
                .andExpect(jsonPath("$[1].title", is("1984")));
        }
    
        @Test
        public void testGetBookById() throws Exception {
            Book book = bookRepository.findAll().get(0);
    
            mockMvc.perform(get("/api/books/" + book.getId())
                    .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.title", is(book.getTitle())));
        }
    
        @Test
        public void testCreateBook() throws Exception {
            Book newBook = new Book(null, "To Kill a Mockingbird", "Harper Lee");
    
            mockMvc.perform(post("/api/books")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(newBook)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.title", is("To Kill a Mockingbird")))
                .andExpect(jsonPath("$.author", is("Harper Lee")));
        }
    }
  2. Run the Tests:

    • Right-click on BookControllerIntegrationTest.java in your IDE and select “Run Tests.”
    • The tests should pass, confirming that the controller, service, and database work together correctly.

Spring Boot Lab 1: Add a New Integration Test

  • Task: Write an integration test for a new endpoint in BookController that updates a book (PUT /api/books/{id}). The endpoint should update the book’s title and author and return the updated book.
  • Hint: Use put() in MockMvc to send the request, and verify the updated book in the database using bookRepository.

Part 2: Express.js Integration Testing

Step 1: Set Up an Express.js Project with a Database

We’ll extend the user management API from the previous tutorial by adding SQLite as the database using better-sqlite3.

  1. Install Dependencies: In your express-user-api project, install the SQLite dependency:

    npm install better-sqlite3
  2. Set Up SQLite Database: Create a database.js file in the project root to initialize the SQLite database:

    const Database = require("better-sqlite3");
    
    const db = new Database(":memory:"); // In-memory database for testing
    
    db.exec(`
        CREATE TABLE users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT NOT NULL,
            email TEXT NOT NULL
        )
    `);
    
    module.exports = db;
  3. Update the Express App: Modify app.js to use the SQLite database:

    const express = require("express");
    const db = require("./database");
    const app = express();
    
    app.use(express.json());
    
    // Seed the database with initial data
    const insert = db.prepare("INSERT INTO users (name, email) VALUES (?, ?)");
    insert.run("Alice", "[email protected]");
    insert.run("Bob", "[email protected]");
    
    app.get("/api/users", (req, res) => {
      const users = db.prepare("SELECT * FROM users").all();
      res.json(users);
    });
    
    app.get("/api/users/:id", (req, res) => {
      const user = db
        .prepare("SELECT * FROM users WHERE id = ?")
        .get(req.params.id);
      if (!user) {
        return res.status(404).json({ message: "User not found" });
      }
      res.json(user);
    });
    
    app.post("/api/users", (req, res) => {
      const { name, email } = req.body;
      const insert = db.prepare("INSERT INTO users (name, email) VALUES (?, ?)");
      const result = insert.run(name, email);
      const newUser = db
        .prepare("SELECT * FROM users WHERE id = ?")
        .get(result.lastInsertRowid);
      res.status(201).json(newUser);
    });
    
    module.exports = app;

Step 2: Write Integration Tests for Express.js

We’ll use Mocha, Chai, and Supertest to test the API, interacting with the SQLite database.

  1. Update the Test File: In test/app.test.js, modify the tests to include database setup and integration testing:

    const request = require("supertest");
    const app = require("../app");
    const expect = require("chai").expect;
    const db = require("../database");
    
    describe("User API Integration Tests", () => {
      beforeEach(() => {
        // Clear the database and seed it with initial data
        db.exec("DELETE FROM users");
        const insert = db.prepare(
          "INSERT INTO users (name, email) VALUES (?, ?)"
        );
        insert.run("Alice", "[email protected]");
        insert.run("Bob", "[email protected]");
      });
    
      describe("GET /api/users", () => {
        it("should return a list of users", async () => {
          const res = await request(app)
            .get("/api/users")
            .expect("Content-Type", /json/)
            .expect(200);
    
          expect(res.body).to.be.an("array");
          expect(res.body).to.have.lengthOf(2);
          expect(res.body[0]).to.have.property("name", "Alice");
        });
      });
    
      describe("GET /api/users/:id", () => {
        it("should return a user by ID", async () => {
          const user = db
            .prepare("SELECT * FROM users WHERE name = ?")
            .get("Alice");
    
          const res = await request(app)
            .get(`/api/users/${user.id}`)
            .expect("Content-Type", /json/)
            .expect(200);
    
          expect(res.body).to.have.property("id", user.id);
          expect(res.body).to.have.property("name", "Alice");
        });
    
        it("should return 404 if user not found", async () => {
          await request(app)
            .get("/api/users/999")
            .expect("Content-Type", /json/)
            .expect(404);
        });
      });
    
      describe("POST /api/users", () => {
        it("should create a new user", async () => {
          const newUser = { name: "Charlie", email: "[email protected]" };
    
          const res = await request(app)
            .post("/api/users")
            .send(newUser)
            .expect("Content-Type", /json/)
            .expect(201);
    
          expect(res.body).to.have.property("name", "Charlie");
          expect(res.body).to.have.property("email", "[email protected]");
    
          const dbUser = db
            .prepare("SELECT * FROM users WHERE name = ?")
            .get("Charlie");
          expect(dbUser).to.exist;
          expect(dbUser.email).to.equal("[email protected]");
        });
      });
    });
  2. Run the Tests:

    • In the terminal, run:
      npm test
    • The tests should pass, confirming that the API interacts correctly with the SQLite database.

Express.js Lab 2: Add a New Integration Test

  • Task: Write an integration test for a new endpoint in app.js that updates a user (PUT /api/users/:id). The endpoint should update the user’s name and email and return the updated user.
  • Hint: Use request(app).put('/api/users/:id') with .send({ name: 'Updated Name', email: '[email protected]' }) to send the payload. Verify the updated user in the database using a query.

Key Concepts and Best Practices

  1. Full Stack Testing: In both Spring Boot and Express.js, we tested the entire stack (controller/routes, service, and database) by making real HTTP requests and interacting with a database.
  2. In-Memory Databases: We used H2 for Spring Boot and SQLite in-memory for Express.js to keep tests fast and independent of external systems.
  3. Setup and Teardown: The @BeforeEach in Spring Boot and beforeEach in Mocha ensured a clean database state for each test, avoiding test interference.
  4. Realistic Scenarios: Integration tests simulate real-world usage, including database operations, making them more comprehensive than unit tests.

Additional Notes

  • Spring Boot: You can use @DataJpaTest for repository-layer tests if you want to focus on database interactions without loading the full application context. @SpringBootTest is better for full integration tests.
  • Express.js: For production applications, you might use a different database (e.g., PostgreSQL or MongoDB). In that case, consider using a test database or Docker to manage test environments.
  • Performance: Integration tests are slower than unit tests because they involve real database operations. Use them sparingly and complement them with unit tests for faster feedback.

This tutorial provides a solid foundation for integration testing in Spring Boot and Express.js. By completing the labs, you’ll gain hands-on experience in setting up and testing full-stack applications, a critical skill for building robust software.


By Wahid Hamdi