Full-Stack Testing — Unit, Integration, and Cypress E2E for the Capstone

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
Note: Tests for the notification service must mock the Bull queue and Socket.io to avoid actually sending emails or opening WebSocket connections during test runs. Use 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.
Tip: Use factory functions for test data that mirror the application’s seed script. A 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.
Warning: Cypress E2E tests for the complete application are the most likely to be flaky because they exercise the entire stack simultaneously. The most common sources of flakiness: Socket.io events arriving before the assertion, async data loading races, and test data from a previous test affecting the current one. Mitigate with: 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

🧠 Test Yourself

A unit test for taskService.update passes, but an integration test for PATCH /api/v1/tasks/:id fails with 403 when the task creator tries to update their own task. What is the most likely cause that unit tests cannot catch?