Testing Strategy — The Testing Pyramid, Tools, and Test Isolation

Testing transforms guesswork into confidence. A MEAN Stack application without tests means every deployment is a gamble, every refactor risks silent regressions, and every bug fix might introduce two new ones. With a well-structured test suite, you can refactor aggressively, deploy continuously, and catch regressions before users do. This lesson establishes the complete testing strategy for the task manager — what to test at each layer, how the three testing levels complement each other, and the practical tooling choices that make testing fast and reliable in a MEAN Stack environment.

Testing Pyramid for MEAN Stack

Level What It Tests Speed Count Tools
Unit Individual functions, service methods, validators, utilities, pure pipes < 1ms each Hundreds Jest (Node) / Jest (Angular)
Integration API endpoints with a real test database — full request-to-response cycle 10–200ms each Dozens Supertest + MongoDB Memory Server
E2E Complete user workflows in a real browser against the running app Seconds each A few per critical path Cypress or Playwright

What to Test at Each Layer

Layer Test Don’t Test
Express services Business logic, data transformations, query construction Framework internals, Mongoose itself
Express middleware Authentication verification, rate limiting, validation Third-party library behaviour
API routes (integration) HTTP status codes, response shapes, auth enforcement, DB state changes Internal implementation details
Angular services HTTP calls (mocked), state transformations, signal updates Angular’s HttpClient internals
Angular components Template rendering, user interaction, @Input/@Output, form validation Framework internals, DOM details
E2E Critical user journeys: register → login → create task → complete task All the happy paths already covered by unit/integration
Note: The testing pyramid is a guideline, not a rigid rule. The key insight is the cost-benefit ratio at each level. Unit tests are cheap (fast, easy to write, deterministic) so write many. Integration tests are slower but catch things unit tests miss (query construction, middleware interaction) — write enough to cover each API endpoint’s key scenarios. E2E tests are expensive (slow, flaky, hard to maintain) — write only for the critical paths that users must be able to complete.
Tip: Use @shelf/jest-mongodb or mongodb-memory-server for integration tests — it spins up a real in-memory MongoDB instance per test suite, runs your actual Mongoose queries, and tears down after. This is far more realistic than mocking Mongoose methods and catches query construction bugs that mocks cannot. The in-memory server starts in about 1 second, making integration tests fast enough to run on every commit.
Warning: Never test against your production or staging database. Use a separate test database or in-memory server. Each test suite should set up and tear down its own data — tests must be independent and able to run in any order. A test that relies on data created by a previous test is fragile and produces false negatives when run in isolation. Use beforeEach to set up a clean state and afterEach to clean up.

Test Setup and Configuration

// package.json — Jest configuration for Express backend
{
    "jest": {
        "testEnvironment":   "node",
        "testMatch":         ["**/__tests__/**/*.test.js", "**/*.test.js"],
        "setupFilesAfterFramework": ["./src/__tests__/setup.js"],
        "coverageDirectory": "coverage",
        "collectCoverageFrom": [
            "src/**/*.js",
            "!src/__tests__/**",
            "!src/server.js"
        ],
        "coverageThresholds": {
            "global": { "branches": 70, "functions": 80, "lines": 80, "statements": 80 }
        }
    },
    "scripts": {
        "test":         "jest",
        "test:watch":   "jest --watch",
        "test:coverage":"jest --coverage",
        "test:integration": "jest --testPathPattern=integration"
    }
}
// src/__tests__/setup.js — global test setup
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');

let mongoServer;

beforeAll(async () => {
    mongoServer = await MongoMemoryServer.create();
    const uri   = mongoServer.getUri();
    await mongoose.connect(uri);
});

afterAll(async () => {
    await mongoose.disconnect();
    await mongoServer.stop();
});

afterEach(async () => {
    // Clear all collections after each test — start fresh
    const collections = mongoose.connection.collections;
    for (const key in collections) {
        await collections[key].deleteMany({});
    }
});

// src/__tests__/factories/user.factory.js — test data factories
const bcrypt = require('bcryptjs');
const User   = require('../../models/user.model');
const jwt    = require('jsonwebtoken');

async function createUser(overrides = {}) {
    const defaults = {
        name:     'Test User',
        email:    `test-${Date.now()}@example.com`,
        password: await bcrypt.hash('Password123!', 12),
        role:     'user',
        isVerified: true,
        isActive:   true,
    };
    return User.create({ ...defaults, ...overrides });
}

async function createAdmin(overrides = {}) {
    return createUser({ role: 'admin', ...overrides });
}

function generateToken(user) {
    return jwt.sign(
        { sub: user._id.toString(), email: user.email, role: user.role },
        process.env.JWT_SECRET || 'test-secret',
        { expiresIn: '1h' }
    );
}

module.exports = { createUser, createAdmin, generateToken };

How It Works

Step 1 — The Test Pyramid Optimises Confidence per Cost

More unit tests than integration tests than E2E tests is not just a stylistic choice — it reflects the cost of each level. A unit test runs in microseconds and requires no infrastructure. An E2E test requires a running server, browser, and database and takes seconds. Writing 500 unit tests costs less wall-clock time than 50 E2E tests. The pyramid ensures maximum confidence coverage per CI minute spent.

Step 2 — In-Memory MongoDB Gives Integration Tests Real Queries

Mocking Mongoose methods (jest.spyOn(Task, 'find').mockResolvedValue([])) tests that your code calls the right Mongoose method — but not that the query actually works. An in-memory MongoDB runs real queries against a real engine. If your query has a typo in a field name, the mock would silently succeed while the real database would return no results. In-memory databases catch this class of bug.

Step 3 — Test Isolation Prevents Ordering Dependencies

The afterEach cleanup that deletes all documents ensures each test starts with an empty database. Tests that depend on state left by previous tests are brittle — they fail when run in isolation (e.g. when a developer runs just one test file) and create hard-to-debug flakiness. Test factories (createUser(), createTask()) create exactly the data each test needs in beforeEach.

Step 4 — Factories Reduce Repetitive Setup Code

Without factories, every test that needs an authenticated user must repeat the same bcrypt, User.create, and jwt.sign code. A createUser(overrides) factory provides sensible defaults while allowing per-test customisation. This keeps test code focused on what is being tested rather than setup boilerplate. If the User schema changes, updating the factory updates all tests.

Step 5 — Coverage Thresholds Enforce Discipline Without Being Dogmatic

Coverage thresholds (80% lines/functions/branches) in CI configuration fail the build when coverage drops below the threshold — preventing gradual test erosion as features are added without tests. 100% coverage is a vanity metric that produces tests of diminishing value; 80% is a practical floor that catches most regressions without requiring tests for trivial code paths.

Quick Reference

Task Tool/Command
Run all tests jest or npm test
Watch mode jest --watch
Coverage report jest --coverage
Run one file jest src/auth.test.js
Run matching pattern jest --testPathPattern=integration
In-memory MongoDB mongodb-memory-server in beforeAll/afterAll
Test data factory Helper functions with defaults + override spread
Clean state between tests afterEach(() => collection.deleteMany({}))
Angular test runner ng test (Karma) or configure Jest with jest-preset-angular

🧠 Test Yourself

Test A creates a user document. Test B expects the users collection to be empty. If Test A runs before Test B, Test B fails. What testing principle is violated and what is the fix?