Month 4, Week 4

Software Testing Methodologies

Building Resilient and Correct Systems

Module 1: The Philosophy of Testing

From "Does it work?" to "Is it correct?"

Why Do We Test?

Testing is not about finding bugs. It is a discipline for building confidence.

  • Confidence that your code does what you think it does (Correctness).
  • Confidence that your code will not break in unexpected ways (Resilience).
  • Confidence that you can change your code in the future without breaking existing functionality (Maintainability).

Automated tests are your safety net.

The Testing Pyramid

This is the architectural blueprint for a healthy testing strategy. It describes the proportion of tests you should have at different levels.

  • Unit Tests (The Foundation): Fast, numerous, and isolated. Test a single function or class.
  • Integration Tests (The Middle): Slower, fewer. Test how multiple components work together.
  • End-to-End (E2E) Tests (The Peak): Slowest, very few. Test the entire application from an external user's perspective.

Module 2: Unit Testing with Jest

Testing the Bricks in Isolation

What is a Unit Test?

A test that verifies the behavior of the smallest possible piece of your application (a "unit") in complete isolation.

For us, a unit is typically a single method within a Service.

To achieve isolation, we must replace real dependencies (like a database repository) with fakes. This is called mocking.

Jest: The Testing Framework

NestJS comes pre-configured with Jest, a popular and powerful testing framework.

Core Jest Functions:

  • `describe(name, fn)`: Creates a block that groups together several related tests.
  • `it(name, fn)` or `test(name, fn)`: Defines an individual test case.
  • `expect(value)`: Creates an "assertion." You use this to declare what you expect the outcome to be.
  • Matchers: Functions that complete the assertion, like `.toBe()`, `.toEqual()`, `.toHaveBeenCalled()`.

The AAA Pattern: Arrange, Act, Assert

A good unit test always follows this structure:


                        it('should add two numbers correctly', () => {
                          // 1. Arrange: Set up the test. Define inputs.
                          const num1 = 5;
                          const num2 = 10;
                          
                          // 2. Act: Execute the code you are testing.
                          const result = calculator.add(num1, num2);

                          // 3. Assert: Check if the outcome is what you expect.
                          expect(result).toBe(15);
                        });
                    

Mocking Dependencies in NestJS

To test a service in isolation, we must mock its dependencies (like a TypeORM repository).

We create a mock object that has the same methods as the real repository, but they return fake, predictable data.


                        // A mock repository object for our tests
                        const mockRepository = {
                          find: jest.fn().mockResolvedValue([{ id: 1, name: 'Test User' }]),
                          findOneBy: jest.fn(),
                          create: jest.fn(dto => ({ id: Date.now(), ...dto })),
                          save: jest.fn(user => Promise.resolve(user)),
                        };
                    

Writing a Service Unit Test

`users.service.spec.ts`


                        import { Test, TestingModule } from '@nestjs/testing';
                        import { UsersService } from './users.service';
                        import { getRepositoryToken } from '@nestjs/typeorm';
                        import { User } from './entities/user.entity';

                        describe('UsersService', () => {
                          let service: UsersService;

                          beforeEach(async () => {
                            const module: TestingModule = await Test.createTestingModule({
                              providers: [
                                UsersService,
                                {
                                  provide: getRepositoryToken(User), // Use the real injection token
                                  useValue: mockRepository,         // But provide our mock object as the value
                                },
                              ],
                            }).compile();

                            service = module.get(UsersService);
                          });

                          it('should find all users', async () => {
                            const users = await service.findAll();
                            expect(users).toEqual([{ id: 1, name: 'Test User' }]);
                            expect(mockRepository.find).toHaveBeenCalled();
                          });
                        });
                    

Mid-Lecture Knowledge Check

Module 3: Integration Testing

Testing How the Bricks Work Together

What is an Integration Test?

A test that verifies the interaction between multiple components of your application.

Instead of mocking the database, an integration test for our API would typically involve:

  1. A running NestJS application instance.
  2. A real, separate test database.
  3. Testing a controller endpoint to see if it correctly calls its service, which in turn correctly interacts with the test database.

Strategy for Integration Tests

The goal is to test a "slice" of your application.

  • Set up a dedicated test database and use environment variables to connect to it when running tests.
  • Use Jest's `beforeEach` or `beforeAll` hooks to ensure the database is in a clean state before each test runs.
  • Test the component from its public interface. For a service, that means calling its public methods. For a controller, that means making a request.

Example: Service Integration Test

This test uses a real test database connection.


                        describe('UsersService (Integration)', () => {
                          let service: UsersService;
                          let connection: DataSource;

                          beforeAll(async () => {
                            // Connect to the REAL test database
                            const module = await Test.createTestingModule({
                                imports: [AppModule], // Import the full app module
                            }).compile();
                            
                            service = module.get(UsersService);
                            connection = module.get(DataSource);
                          });

                          beforeEach(async () => {
                            // Clean the users table before each test
                            await connection.getRepository(User).clear();
                          });

                          it('should create a user and find them', async () => {
                            const userDto = { name: 'Integration Test', email: 'test@int.com' };
                            const createdUser = await service.create(userDto);
                            const foundUser = await service.findOne(createdUser.id);
                            
                            expect(foundUser.name).toBe(userDto.name);
                          });
                        });
                    

Module 4: End-to-End (E2E) Testing

Testing the Entire Skyscraper

What is an E2E Test?

An E2E test treats your application as a black box. It tests the entire system from the outside, exactly as a real user or client application would.

For a REST API, this means:

  1. Starting your actual application server.
  2. Making real HTTP requests to its endpoints.
  3. Asserting that the HTTP responses (status codes, headers, and body) are correct.

Supertest: The HTTP Testing Library

NestJS uses Jest as the test runner, but it uses a library called Supertest to make the HTTP requests.

Supertest provides a clean, chainable API for sending requests and making assertions against the response.


                        import * as request from 'supertest';

                        // ... inside an E2E test ...

                        it('/POST users', () => {
                          return request(app.getHttpServer()) // `app` is our running Nest app
                            .post('/users')
                            .send({ name: 'E2E Test', email: 'e2e@test.com' })
                            .expect(201) // Assert the status code
                            .expect(res => {
                                // Assert the response body
                                expect(res.body.name).toEqual('E2E Test');
                            });
                        });
                     

E2E Test Structure

The NestJS CLI generates a boilerplate E2E test file (`.e2e-spec.ts`) that handles the setup for you.


                        describe('AppController (e2e)', () => {
                          let app: INestApplication;

                          beforeEach(async () => {
                            const moduleFixture: TestingModule = await Test.createTestingModule({
                              imports: [AppModule],
                            }).compile();

                            app = moduleFixture.createNestApplication();
                            await app.init();
                          });
                          
                          afterAll(async () => {
                            await app.close();
                          });

                          it('/GET', () => {
                            return request(app.getHttpServer())
                              .get('/')
                              .expect(200)
                              .expect('Hello World!');
                          });
                        });
                    

In-Class Practical Exercise

Writing Unit Tests for a `BooksService`

You will be given a `BooksService` that has methods for `create`, `findAll`, and `findOne`. Your task is to write the complete unit test file for this service, achieving 100% test coverage.

  1. Create the test file: `books.service.spec.ts`.
  2. Create a mock `booksRepository` object with `jest.fn()` for the `create`, `save`, `find`, and `findOneBy` methods.
  3. Set up the `TestingModule` in a `beforeEach` block, providing your mock repository.
  4. Write a `describe` block for the `create` method.
    • Write an `it` test case to ensure it calls `repository.create` and `repository.save` with the correct data.
  5. Write a `describe` block for the `findAll` method.
    • Write an `it` test case to ensure it calls `repository.find` and returns the mocked array of books.
  6. Write a `describe` block for the `findOne` method.
    • Write an `it` test for the "happy path" where a book is found.
    • Write another `it` test for the "unhappy path" where the book is not found, and assert that it throws a `NotFoundException`.
  7. Run the tests using `npm run test`.

Final Knowledge Check