Skip to main content

Overview

Tests in Ship applications are optional by default. The template excludes testing dependencies to keep it lightweight and focused on rapid development. However, as your project grows in complexity or requires higher reliability, testing becomes essential. Testing is implemented using the Jest framework and MongoDB memory server. The setup supports execution in CI/CD pipelines. MongoDB memory server allows connecting to an in-memory MongoDB instance and running integration tests in isolation, ensuring reliable and fast test execution without affecting your production database.

When to Add Testing

  • Complex and confusing business rules
  • Critical calculations/algorithms
  • Core flows that affect other features
  • Logic reused across the applications
  • Areas with recurring hard bugs

When to Skip Testing

  • Simple CRUD operations
  • Prototype/MVP development
  • Short-term projects
  • Basic UI components
  • Static content pages

Installation and Setup

Installing Dependencies

Add the necessary testing packages to your project:
pnpm add -D --filter=api \
     jest \
     @types/jest \
     ts-jest \
     @shelf/jest-mongodb \
     mongodb-memory-server \
     supertest \
     @types/supertest \
     dotenv

Jest Configuration

Navigate to the root of apps/api. Create configuration file for jest:
jest.config.js
/** @type {import('jest').Config} */
const config = {
  preset: '@shelf/jest-mongodb',
  verbose: true,
  testEnvironment: 'node',
  testMatch: ['**/?(*.)+(spec.ts)'],
  transform: {
    '^.+\\.(ts|tsx)$': ['ts-jest', { useESM: true, diagnostics: false }],
  },
  extensionsToTreatAsEsm: ['.ts', '.tsx'],
  watchPathIgnorePatterns: ['globalConfig'],
  roots: ['<rootDir>'],
  modulePaths: ['src'],
  moduleDirectories: ['node_modules'],
  testTimeout: 10000,
  forceExit: true,
  detectOpenHandles: true,
  setupFiles: ['dotenv/config'],
};

export default config;
After, create configuration file for jest-mongodb:
jest-mongodb-config.js
module.exports = {
  mongoURLEnvName: 'MONGO_URI',
  mongodbMemoryServerOptions: {
    binary: {
      version: '8.0.0',
    },
    autoStart: false,
  },
};
Jest requires set of environment variables to work properly. Create .env.test file in the root of apps/api and set the following variables:
.env.test
APP_ENV=staging

API_URL=http://localhost:3001
WEB_URL=http://localhost:3002

MONGO_DB_NAME=api-tests

package.json scripts

Add test scripts to your apps/api/package.json:
apps/api/package.json
{
  "scripts": {
    "test": "NODE_OPTIONS=--experimental-vm-modules DOTENV_CONFIG_PATH=.env.test jest --runInBand",
    "test:watch": "NODE_OPTIONS=--experimental-vm-modules DOTENV_CONFIG_PATH=.env.test jest --watch",
    "test:coverage": "NODE_OPTIONS=--experimental-vm-modules DOTENV_CONFIG_PATH=.env.test jest --coverage",
  }
}

Test Structure

Tests should be placed next to the code they are testing inside a tests/ folder. Use *.spec.ts suffixes and standardize by unit type:
  • .action.spec.ts — for action handler + validator (HTTP via supertest)
  • .service.spec.ts — for data/service layer
  • .validator.spec.ts — for standalone schema/validators

API resource example

apps/api/src/resources/user/
├── actions/
│   ├── create.ts
│   └── tests/
│       └── create.action.spec.ts
├── user.service.ts
├── user.routes.ts
└── tests/
    ├── user.service.spec.ts
    └── factories/
        └── user.factory.ts

Utilities

Colocate tests for utility modules. Keep them small and pure (no DB). Create tests/ folder inside utils
apps/api/src/utils/
├── cookie.util.ts
├── promise.util.ts
├── security.util.ts
└── tests/
    ├── cookie.util.spec.ts
    ├── promise.util.spec.ts
    └── security.util.spec.ts

Testing Examples

Service Integration Test Example

user.service.spec.ts
import { generateId } from '@paralect/node-mongo';

import { userService } from 'resources/user';

describe('user service', () => {
  beforeEach(async () => await userService.deleteMany({}));

  it('should create user', async () => {
    const mockUser = {
      _id: generateId(),
      firstName: 'John',
      lastName: 'Doe',
      email: '[email protected]',
      isEmailVerified: false,
    };

    await userService.insertOne(mockUser, { publishEvents: false });

    const insertedUser = await userService.findOne({ _id: mockUser._id });

    expect(insertedUser).not.toBeNull();
    expect(insertedUser?.email).toBe(mockUser.email);
  });

  it('should update user', async () => {
    const user = await userService.insertOne(
      {
        _id: generateId(),
        firstName: 'John',
        lastName: 'Doe',
        email: '[email protected]',
        isEmailVerified: false,
      },
      { publishEvents: false },
    );

    await userService.updateOne({ _id: user._id }, () => ({ isEmailVerified: true }), { publishEvents: false });

    const updatedUser = await userService.findOne({ _id: user._id });
    expect(updatedUser?.isEmailVerified).toBe(true);
  });
});

API Action Test Example

Test your API endpoints:
sign-up.action.spec.ts
import app from 'app';
import request from 'supertest';

import { tokenService } from 'resources/token';
import { userService } from 'resources/user';

describe('post /account/sign-up', () => {
  beforeEach(async () => await Promise.all([userService.deleteMany({}), tokenService.deleteMany({})]));

  it('should create user with valid data', async () => {
    const userData = {
      firstName: 'John',
      lastName: 'Doe',
      email: '[email protected]',
      password: 'Password123!',
    };

    await request(app.callback()).post('/account/sign-up').send(userData).expect(204);
  });

  it('should return validation error for invalid email', async () => {
    const userData = {
      firstName: 'John',
      lastName: 'Doe',
      email: 'invalid-email',
      password: 'Password123!',
    };

    await request(app.callback()).post('/account/sign-up').send(userData).expect(400);
  });
});

Testing Utilities

security.util.spec.ts
import { securityUtil } from 'utils';

describe('security utils', () => {
  describe('generateSecureToken', () => {
    it('should generate token of specified length', async () => {
      const token = await securityUtil.generateSecureToken(32);

      expect(token).toHaveLength(32);
      expect(typeof token).toBe('string');
    });
  });

  describe('hashPassword', () => {
    it('should hash password correctly', async () => {
      const password = 'test-password-123';
      const hash = await securityUtil.hashPassword(password);

      expect(hash).not.toBe(password);

      const isValid = await securityUtil.verifyPasswordHash(hash, password);
      expect(isValid).toBe(true);
    });
  });
});

Best Practices

Test Isolation

Each test should be isolated from the others. Use beforeEach to clean the database before each test.
describe('user service', () => {
  beforeEach(async () => {
    // Clean database before each test
    await userService.deleteMany({});
  });
});

Test Naming

Use descriptive test names that explain the expected behavior:
// Good
it('should return user data when valid ID is provided', () => {});

// Bad
it('should work', () => {});

Mock External Services

Mock external API calls and services:
jest.mock('@aws-sdk/client-s3', () => ({
  S3Client: jest.fn(),
  PutObjectCommand: jest.fn(),
}));

title: “Testing”

Overview

In Ship testing settled through Jest framework and MongoDB memory server with possibility running them in CI/CD pipeline. MongoDB’s memory server allows connecting to the MongoDB server and running integration tests isolated. Tests should be placed in the tests directory specified for each resource from the resources folder and have next naming format user.service.spec.ts.
apps/api/src/resources/user/tests/user.service.spec.ts
resources/
  user/
    tests/
      user.service.spec.ts
Run tests and linter.
pnpm run test
Run only tests.
pnpm run test:unit

Example

import { Database } from '@paralect/node-mongo';

import { DATABASE_DOCUMENTS } from 'app-constants';

import { User } from 'types';
import { userSchema } from 'schemas';

const database = new Database(process.env.MONGO_URL as string);

const userService = database.createService<User>(DATABASE_DOCUMENTS.USERS, {
  schemaValidator: (obj) => userSchema.parseAsync(obj),
});

describe('User service', () => {
  beforeAll(async () => {
    await database.connect();
  });

  it('should insert doc to collection', async () => {
    const mockUser = { _id: '12q', name: 'John' };

    await userService.insertOne(mockUser);

    const insertedUser = await userService.findOne({ _id: mockUser._id });

    expect(insertedUser).toEqual(mockUser);
  });

  afterAll(async () => {
    await database.close();
  });
});

GitHub Actions

By default, tests run for each pull request to the main branch through the run-tests.yml workflow.
.github/workflows/run-tests.yml
name: run-tests

on:
  pull_request:
    branches:
      - main

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [ 16.x ]
    steps:
      - uses: actions/checkout@v2
      - name: Test api using jest
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - run: npm install
      - run: npm test
To set up pull request rejection if tests failed visit Settings > Branches tab in your repository. Then add the branch protection rule “Require status checks to pass before merging”.
I