Day 17: Unit test for auth endpoint using Jest. Success: Pass signup test.

Lesson 17 60 min

Day 17: Unit Testing Our Auth Endpoint with Jest – For Hyperscale Reliability

Welcome back, fellow architects and engineers!

Today, we're diving deep into a topic that often gets overlooked in the rush to build features: Unit Testing. Specifically, we're going to unit test our authentication signup endpoint using Jest. You might think, "Just another test, what's the big deal?" But for critical paths like authentication in a hyperscale CRM, the "big deal" is the difference between a secure, reliable system and a catastrophic data breach.

Agenda: Securing the Gates

  1. Why Unit Test Authentication? Beyond the obvious.

  2. Core Concepts: The Testing Pyramid, Isolation, and the "Contract" of Code.

  3. Component Architecture: Where our unit tests fit.

  4. Control & Data Flow: How our tests verify the signup process.

  5. Hands-on Build-Along: Implementing our Jest tests.

  6. Hyperscale Implications: Why this matters at 100M RPS.

1. Why Unit Test Authentication? Beyond the Obvious.

In the world of CRM, user authentication is the absolute frontline. It's the gatekeeper to all sensitive customer data. A single flaw here isn't just a bug; it's a gaping security vulnerability.

Most engineers understand that they should test. But the why for authentication is profoundly different. Imagine a bug in your signup process that allows:

  • Duplicate user creation with the same email, leading to data inconsistencies.

  • Weak password hashing, making your user database vulnerable to rainbow table attacks.

  • Improper token generation, allowing unauthorized access.

The cost of such a bug in production? Think brand reputation shattered, massive data breach fines, legal battles, and the complete erosion of customer trust. At big tech, a single authentication vulnerability can lead to billions in damage and recovery costs.

Insight: Unit tests for authentication aren't just about verifying functionality; they're about enforcing a security contract for the most critical entry point of your system. They are your first, fastest, and cheapest line of defense against catastrophic failures. They define exactly how your authentication mechanism must behave under all specified conditions, ensuring that even a subtle change doesn't silently introduce a vulnerability.

2. Core Concepts: The Testing Pyramid, Isolation, and the "Contract" of Code

  • The Testing Pyramid: You've probably seen it. Many small, fast unit tests at the base; fewer integration tests in the middle; and even fewer, slower end-to-end tests at the top. Today, we're focused on the base: unit tests. They run quickly, give immediate feedback, and pinpoint issues precisely.

  • Isolation (Core System Design Concept): A true unit test isolates a "unit" of code (e.g., a single function, a class method) from its external dependencies. This means we mock or stub anything the unit interacts with—database calls, external APIs, even other modules. This ensures that if a test fails, you know the problem is within that specific unit, not in one of its dependencies. For our authService.signup function, we'll mock the userRepository (which would normally interact with the database) and bcrypt (for password hashing).

  • The "Contract" of Code: Every function, every method, implicitly has a contract: "Given these inputs, I will produce these outputs or side effects." A unit test makes this contract explicit. It’s living documentation that ensures your code adheres to its agreed-upon behavior, even as the system evolves. This is invaluable in large teams and rapidly changing codebases.

3. Component Architecture: Where Our Unit Tests Fit

Component Architecture

Jest Test Suite Auth Service signup(email, password) login(email, password) validateToken(jwt) Mock UserRepository Mock bcrypt Mock jsonwebtoken Calls Uses (Mocked)

Our CRM's authentication flow typically involves:

  1. Router (authRoutes.js): Defines the API endpoint (/api/auth/signup).

  2. Controller (authController.js): Handles HTTP request/response, calls the service.

  3. Service (authService.js): Contains the core business logic (hashing password, creating user, generating token).

  4. Repository (userRepository.js): Interacts with the database.

For unit testing, we want to test the authService.js in isolation. This means we'll mock its dependencies:

  • userRepository: Mock its findByEmail and create methods.

  • bcrypt: Mock its hash method.

  • jsonwebtoken: Mock its sign method.

This setup allows us to verify the signup logic without actually hitting a database or generating real tokens, making the tests fast and reliable.

4. Control & Data Flow: Verifying the Signup Process

Flowchart

Test Start Call authService.signup User Exists? No Mock bcrypt.hash Mock create user Assert Success Yes Assert Error Test End

Let's trace the happy path for signup and how our tests will verify it:

Control Flow (Unit Test Perspective):

  1. Test calls authService.signup(email, password).

  2. authService checks if user exists (via mocked userRepository.findByEmail).

  3. authService hashes password (via mocked bcrypt.hash).

  4. authService creates user (via mocked userRepository.create).

  5. authService generates token (via mocked jsonwebtoken.sign).

  6. authService returns user and token.

  7. Test asserts the return values match expectations and mocks were called correctly.

Data Flow:
email, password -> authService.signup -> (mocked userRepository.findByEmail returns null) -> (mocked bcrypt.hash returns hashedPassword) -> (mocked userRepository.create receives email, hashedPassword) -> (mocked jsonwebtoken.sign receives user ID) -> token -> authService returns { user, token }.

Our unit tests will ensure each step of this data transformation and control flow is correct.

5. Hands-on Build-Along: Implementing Our Jest Tests

We'll focus on authService.js for our unit test.

First, ensure you have Jest installed: npm install --save-dev jest.
Add a script to your package.json: "test": "jest".

Let's assume our authService.js looks something like this (we'll implement this in the script):

javascript
// src/services/authService.js
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const userRepository = require('../utils/userRepository'); // A simple mock for now

const JWT_SECRET = process.env.JWT_SECRET || 'supersecretjwtkey';

const authService = {
  async signup(email, password) {
    // 1. Check if user already exists
    const existingUser = await userRepository.findByEmail(email);
    if (existingUser) {
      throw new Error('User already exists with this email.');
    }

    // 2. Hash password
    const hashedPassword = await bcrypt.hash(password, 10);

    // 3. Create user
    const newUser = await userRepository.create({ email, password: hashedPassword });

    // 4. Generate JWT token
    const token = jwt.sign({ id: newUser.id, email: newUser.email }, JWT_SECRET, { expiresIn: '1h' });

    return { user: { id: newUser.id, email: newUser.email }, token };
  },
  // ... other auth methods like login
};

module.exports = authService;

Now, let's write our unit test in tests/unit/authService.test.js:

javascript
// tests/unit/authService.test.js
const authService = require('../../src/services/authService');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const userRepository = require('../../src/utils/userRepository'); // Our mock target

// Mock all dependencies
jest.mock('bcryptjs');
jest.mock('jsonwebtoken');
jest.mock('../../src/utils/userRepository');

describe('Auth Service Unit Tests', () => {
  beforeEach(() => {
    // Clear all mocks before each test to ensure isolation
    jest.clearAllMocks();
  });

  it('should successfully sign up a new user', async () => {
    // Arrange: Define mock behavior
    const mockEmail = 'test@example.com';
    const mockPassword = 'password123';
    const mockHashedPassword = 'hashedPassword123';
    const mockUserId = 'user123';
    const mockToken = 'mockJwtToken';

    userRepository.findByEmail.mockResolvedValue(null); // User does not exist
    bcrypt.hash.mockResolvedValue(mockHashedPassword); // Password gets hashed
    userRepository.create.mockResolvedValue({ id: mockUserId, email: mockEmail, password: mockHashedPassword }); // User created
    jwt.sign.mockReturnValue(mockToken); // Token generated

    // Act: Call the service method
    const result = await authService.signup(mockEmail, mockPassword);

    // Assert: Verify outcomes
    expect(userRepository.findByEmail).toHaveBeenCalledTimes(1);
    expect(userRepository.findByEmail).toHaveBeenCalledWith(mockEmail);

    expect(bcrypt.hash).toHaveBeenCalledTimes(1);
    expect(bcrypt.hash).toHaveBeenCalledWith(mockPassword, 10);

    expect(userRepository.create).toHaveBeenCalledTimes(1);
    expect(userRepository.create).toHaveBeenCalledWith({ email: mockEmail, password: mockHashedPassword });

    expect(jwt.sign).toHaveBeenCalledTimes(1);
    expect(jwt.sign).toHaveBeenCalledWith(
      { id: mockUserId, email: mockEmail },
      expect.any(String), // JWT_SECRET
      { expiresIn: '1h' }
    );

    expect(result).toEqual({
      user: { id: mockUserId, email: mockEmail },
      token: mockToken,
    });
  });

  it('should throw an error if user already exists', async () => {
    // Arrange
    const mockEmail = 'existing@example.com';
    const mockPassword = 'password123';

    userRepository.findByEmail.mockResolvedValue({ id: 'existingUser', email: mockEmail }); // User exists

    // Act & Assert
    await expect(authService.signup(mockEmail, mockPassword)).rejects.toThrow('User already exists with this email.');

    expect(userRepository.findByEmail).toHaveBeenCalledTimes(1);
    expect(userRepository.findByEmail).toHaveBeenCalledWith(mockEmail);
    // Ensure no further calls were made if user exists
    expect(bcrypt.hash).not.toHaveBeenCalled();
    expect(userRepository.create).not.toHaveBeenCalled();
    expect(jwt.sign).not.toHaveBeenCalled();
  });

  // Add more tests for error handling, edge cases, etc.
});

To make userRepository mockable, we'll need a simple placeholder:

javascript
// src/utils/userRepository.js
// This is a placeholder for actual database interaction.
// In a real app, this would connect to Postgres, MongoDB, etc.
const userRepository = {
    findByEmail: async (email) => {
        // Simulate DB call
        return null; // By default, no user found
    },
    create: async (userData) => {
        // Simulate DB call
        return { id: 'mockId', ...userData }; // Return a mock user
    }
};

module.exports = userRepository;

6. Hyperscale Implications: Why This Matters at 100M RPS

State Machine

Pending Signup User Exists Check Password Hashing User Creation Token Generation Signup Success Signup Failed Request Not Found Hashed Stored Issued Exists System Error

At 100 million requests per second, your CI/CD pipeline needs to be lightning-fast. Every second counts. If your tests are slow, deployments slow down, and your ability to iterate rapidly diminishes.

  • Speed: Unit tests are inherently fast because they don't touch external systems. This speed is critical for quick feedback loops in a high-velocity development environment. Imagine waiting minutes or hours for integration tests to run for every small code change.

  • Reliability: In distributed systems, where authentication might be handled by a dedicated microservice, unit tests provide a high degree of confidence that each service component is robust before it even interacts with other services. This prevents cascading failures.

  • Refactoring Confidence: As your CRM scales, its authentication mechanisms will evolve. You'll switch hashing algorithms, update token strategies, or integrate with new identity providers. A comprehensive suite of unit tests allows engineers to refactor critical code with confidence, knowing that if they break the "contract," the tests will immediately flag it. Without this, refactoring auth code at hyperscale is a terrifying prospect.

Practical Takeaway: Don't just write unit tests; write meaningful unit tests that enforce the contract of your most critical components. For authentication, this means thoroughly testing happy paths, error conditions, and security-relevant edge cases in complete isolation. This practice is a cornerstone of building resilient, secure, and rapidly evolving hyperscale systems.


Assignment: Expand Auth Service Unit Tests

Your mission, should you choose to accept it, is to expand our authService.test.js to cover more scenarios.

  1. Add a login method to authService.js:

  • It should take email and password.

  • Find the user by email using userRepository.findByEmail.

  • Compare the provided password with the stored hashed password using bcrypt.compare.

  • If credentials are valid, generate a JWT token using jwt.sign.

  • Return { user, token }.

  • Throw an error if the user is not found or the password is incorrect.

  1. Write unit tests for the login method in authService.test.js:

  • Test the successful login scenario.

  • Test the scenario where the user is not found.

  • Test the scenario where the password is incorrect.

  • Ensure all relevant mocks (userRepository.findByEmail, bcrypt.compare, jwt.sign) are called correctly and with the right arguments, and that mocks not relevant to the specific failure path are not called.

Solution Hints:

  • For authService.js login method:

  • Remember to import bcrypt and jwt.

  • bcrypt.compare(plainPassword, hashedPassword) returns a boolean.

  • Ensure your userRepository.findByEmail mock for login returns a user with a password field that can be compared.

  • For authService.test.js login tests:

  • You'll need to mock bcrypt.compare. Its mock implementation should return true for valid login and false for invalid password.

  • Mock userRepository.findByEmail to return a mock user object with a hashedPassword for successful login, and null for user not found.

  • Mock jwt.sign to return a mock token.

  • Use expect(...).rejects.toThrow(...) for error scenarios.

  • Use toHaveBeenCalledWith to verify mock arguments.

  • Use not.toHaveBeenCalled() to ensure functions aren't called in error paths.

Good luck, and remember: robust testing is the bedrock of robust systems!

Need help?