Testing the capstone application requires all three levels of the testing pyramid: unit tests for the service layer business logic, integration tests for the API endpoints with a real in-memory MongoDB, and Cypress E2E tests for the critical user journeys. With the full application built, testing reveals not just correctness but the quality of the architecture — well-structured code is easy to test; tightly coupled code is difficult. This lesson applies the testing patterns from Chapter 19 to the complete Task Manager, demonstrating what to test at each layer and how to make tests fast and reliable.
Test Coverage Targets
| Module | Unit Tests | Integration Tests | E2E Tests |
|---|---|---|---|
| Auth service | register, login, refresh, password reset logic | POST /auth/register, login, refresh | Register and login flow |
| Task service | getAll filters, update permissions, soft delete | CRUD endpoints, workspace scoping, caching | Create, complete, delete task |
| Workspace service | slug generation, invite logic, role checks | Invite + accept flow, role update | Create workspace, invite member |
| Notification service | fanout logic, unread count update | GET /notifications, mark-all-read | Notification bell unread count |
| Angular TaskStore | Optimistic create/update/delete with rollback | N/A | Task list real-time update |
| Angular AuthStore | login/logout signal state, initialize | N/A | Token refresh on expiry |
jest.mock('../../queues') to auto-mock the email queue. For Socket.io, inject a mock io object: const mockIo = { to: jest.fn().mockReturnThis(), emit: jest.fn() }. The service tests verify that the notification was created in MongoDB and that the correct Socket.io and queue calls were made — not that emails were actually delivered.createWorkspaceWithMembers(ownerOverrides, memberCount) factory creates a workspace with an owner and N members in one call, returning all the created documents. Integration tests that need a specific workspace setup call this factory in beforeEach. Consistent factories across unit and integration tests mean changes to schemas only require updating factories in one place.cy.intercept().as('alias') + cy.wait('@alias') for API-driven assertions, cy.cleanupWorkspace() in beforeEach to reset state, and generous but not unlimited timeouts (timeout: 10000).Complete Test Suite
// ── Unit: task service permission logic ───────────────────────────────────
// apps/api/src/__tests__/unit/task.service.test.js
jest.mock('../../services/cache.service');
jest.mock('../../modules/tasks/task.model');
const taskService = require('../../modules/tasks/task.service');
const Task = require('../../modules/tasks/task.model');
const { AuthorizationError, NotFoundError } = require('../../errors/app-errors');
describe('TaskService.update', () => {
const workspaceId = new mongoose.Types.ObjectId().toString();
const ownerId = new mongoose.Types.ObjectId().toString();
const memberId = new mongoose.Types.ObjectId().toString();
afterEach(() => jest.clearAllMocks());
it('allows the task creator to update', async () => {
Task.findOne.mockResolvedValue({ _id: 'task1', createdBy: { toString: () => ownerId }, workspace: workspaceId });
Task.findByIdAndUpdate.mockResolvedValue({ title: 'Updated' });
const result = await taskService.update('task1', workspaceId, { title: 'Updated' }, ownerId, 'member');
expect(result.title).toBe('Updated');
});
it('allows workspace admin to update any task', async () => {
Task.findOne.mockResolvedValue({ _id: 'task1', createdBy: { toString: () => ownerId }, workspace: workspaceId });
Task.findByIdAndUpdate.mockResolvedValue({ title: 'Updated by Admin' });
await expect(
taskService.update('task1', workspaceId, { title: 'Updated by Admin' }, memberId, 'admin')
).resolves.toBeTruthy();
});
it('throws AuthorizationError when non-creator member tries to update', async () => {
Task.findOne.mockResolvedValue({ _id: 'task1', createdBy: { toString: () => ownerId }, workspace: workspaceId });
await expect(
taskService.update('task1', workspaceId, { title: 'Hijack' }, memberId, 'member')
).rejects.toThrow(AuthorizationError);
});
it('throws NotFoundError for non-existent task', async () => {
Task.findOne.mockResolvedValue(null);
await expect(
taskService.update('nonexistent', workspaceId, {}, ownerId, 'owner')
).rejects.toThrow(NotFoundError);
});
});
// ── Integration: task endpoints ────────────────────────────────────────────
// apps/api/src/__tests__/integration/task.routes.test.js
const request = require('supertest');
const app = require('../../app');
const Task = require('../../modules/tasks/task.model');
const { createUser, createWorkspace, generateToken } = require('../factories');
describe('Task Routes', () => {
let owner, member, viewer, workspace, ownerToken, memberToken, viewerToken;
beforeEach(async () => {
owner = await createUser({ name: 'Owner' });
member = await createUser({ name: 'Member' });
viewer = await createUser({ name: 'Viewer' });
workspace = await createWorkspace(owner._id, [
{ userId: member._id, role: 'member' },
{ userId: viewer._id, role: 'viewer' },
]);
ownerToken = generateToken(owner);
memberToken = generateToken(member);
viewerToken = generateToken(viewer);
});
describe('POST /api/v1/tasks', () => {
it('member can create a task', async () => {
const res = await request(app)
.post('/api/v1/tasks')
.set('Authorization', `Bearer ${memberToken}`)
.send({ title: 'Test Task', workspaceId: workspace._id.toString() })
.expect(201);
expect(res.body.data.title).toBe('Test Task');
expect(res.body.data.workspace).toBe(workspace._id.toString());
});
it('viewer cannot create a task', async () => {
await request(app)
.post('/api/v1/tasks')
.set('Authorization', `Bearer ${viewerToken}`)
.send({ title: 'Viewer Task', workspaceId: workspace._id.toString() })
.expect(403);
});
it('member from different workspace cannot create task', async () => {
const otherUser = await createUser();
await request(app)
.post('/api/v1/tasks')
.set('Authorization', `Bearer ${generateToken(otherUser)}`)
.send({ title: 'Infiltrated Task', workspaceId: workspace._id.toString() })
.expect(403);
});
});
describe('PATCH /api/v1/tasks/:id', () => {
it('member cannot update another member\'s task', async () => {
const task = await Task.create({
title: 'Owner Task', workspace: workspace._id, createdBy: owner._id,
});
await request(app)
.patch(`/api/v1/tasks/${task._id}`)
.set('Authorization', `Bearer ${memberToken}`)
.send({ title: 'Hijacked' })
.expect(403);
const unchanged = await Task.findById(task._id);
expect(unchanged.title).toBe('Owner Task');
});
it('admin can update any task in the workspace', async () => {
const adminUser = await createUser();
await workspace.updateOne({ $push: { members: { userId: adminUser._id, role: 'admin' } } });
const task = await Task.create({
title: 'Member Task', workspace: workspace._id, createdBy: member._id,
});
await request(app)
.patch(`/api/v1/tasks/${task._id}`)
.set('Authorization', `Bearer ${generateToken(adminUser)}`)
.send({ title: 'Updated by Admin' })
.expect(200);
});
});
describe('DELETE /api/v1/tasks/:id', () => {
it('soft deletes task — sets deletedAt, does not remove document', async () => {
const task = await Task.create({
title: 'Task to Delete', workspace: workspace._id, createdBy: owner._id,
});
await request(app)
.delete(`/api/v1/tasks/${task._id}`)
.set('Authorization', `Bearer ${ownerToken}`)
.expect(204);
const softDeleted = await Task.findById(task._id);
expect(softDeleted).not.toBeNull();
expect(softDeleted.deletedAt).toBeDefined();
});
it('soft-deleted task does not appear in list', async () => {
const task = await Task.create({
title: 'Deleted Task', workspace: workspace._id, createdBy: owner._id,
deletedAt: new Date(),
});
const res = await request(app)
.get(`/api/v1/tasks?workspaceId=${workspace._id}`)
.set('Authorization', `Bearer ${ownerToken}`)
.expect(200);
expect(res.body.data.map(t => t._id)).not.toContain(task._id.toString());
});
});
});
// ── Cypress E2E: task management ──────────────────────────────────────────
// cypress/e2e/tasks.cy.js
describe('Task Management', () => {
beforeEach(() => {
cy.login('owner@test.io', 'Password123!');
cy.cleanupTasks();
cy.visit('/w/test-workspace/tasks');
});
it('creates a task and it appears in the list', () => {
cy.get('[data-testid=new-task-btn]').click();
cy.get('[data-testid=title-input]').type('E2E Test Task');
cy.get('[data-testid=priority-btn-high]').click();
cy.intercept('POST', '/api/v1/tasks').as('create');
cy.get('[data-testid=submit-btn]').click();
cy.wait('@create').its('response.statusCode').should('eq', 201);
cy.contains('E2E Test Task').should('be.visible');
cy.get('[data-testid=priority-badge]').first().should('contain', 'high');
});
it('real-time: task created by another user appears without page refresh', () => {
// Simulate another user creating a task via direct API call
cy.request({
method: 'POST',
url: `${Cypress.env('apiUrl')}/tasks`,
headers: { Authorization: `Bearer ${Cypress.env('otherUserToken')}` },
body: { title: 'Remote Task', workspaceId: Cypress.env('workspaceId') },
});
// Should appear via Socket.io without page refresh
cy.contains('Remote Task', { timeout: 5000 }).should('be.visible');
});
it('filters tasks by status', () => {
cy.createTask({ title: 'Pending Task', status: 'todo' });
cy.createTask({ title: 'Done Task', status: 'done' });
cy.reload();
cy.get('[data-testid=status-filter]').select('todo');
cy.contains('Pending Task').should('be.visible');
cy.contains('Done Task').should('not.exist');
});
});
How It Works
Step 1 — Unit Tests Mock the Model and Cache
The task service depends on the Task Mongoose model and the CacheService. Unit tests mock both with jest.mock(), replacing them with jest functions that can be controlled per test. This isolates the permission logic — the test verifies “given this task owner and this requester role, is access allowed?” — without touching the database or Redis. Each test case covers one specific permission scenario.
Step 2 — Integration Tests Use Real Factories and Real Queries
The integration test factories create real MongoDB documents with the correct relationships. A workspace factory creates a workspace with the specified members already added, returning all created users and the workspace document. Integration tests verify the entire chain: HTTP request → middleware → validation → service → database → response. They catch bugs that unit tests miss, such as the workspace middleware not correctly populating req.workspaceMember.
Step 3 — Soft Delete Test Verifies the Pattern Correctly
Two separate tests cover soft delete: one verifies deletedAt is set and the document still exists (the delete worked correctly), and one verifies the deleted task does not appear in the list (the filter works correctly). Testing both is necessary — a bug that deletes the document hard would pass the list test but fail the document existence test, and a bug where deletedAt is set but the list filter is wrong would pass the existence test but fail the list test.
Step 4 — Real-Time E2E Test Verifies Socket.io Pipeline End-to-End
The real-time Cypress test makes a direct API call (as another user) to create a task while the first user is viewing the list. It then asserts the task appears in the UI without a page refresh — verifying the complete pipeline: API write → Change Stream event → Socket.io broadcast → Angular store update → template render. This is the only test level that can verify this multi-layer, multi-component flow.
Step 5 — Cross-Workspace Access Test Verifies the Security Boundary
The integration test “member from different workspace cannot create task” creates a user who is not a member of the target workspace and verifies they receive 403. This is the most important security test — it verifies the workspace middleware correctly prevents cross-workspace access. Without this test, a workspace membership check bug might go unnoticed until a security researcher finds it.
Quick Reference
| Task | Test Code |
|---|---|
| Mock Mongoose model | jest.mock('../../models/task.model'); Task.findOne.mockResolvedValue(data) |
| Mock Bull queue | jest.mock('../../queues'); emailQueue.add.mockResolvedValue({}) |
| Create test workspace | createWorkspace(ownerId, [{ userId, role: 'member' }]) |
| Test permission error | await expect(service.update(...)).rejects.toThrow(AuthorizationError) |
| Test soft delete | Assert document exists AND has deletedAt defined |
| Test soft delete filter | Assert deleted task ID not in list response |
| Cypress real-time test | Direct API call → cy.contains(title, { timeout: 5000 }) |
| Cypress cleanup | cy.cleanupTasks() in beforeEach |