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 |
@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.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 |