Introduccion al testing con nodejs + typescript + jest

Introduccion al testing con nodejs + typescript + jest

ยท

5 min read

En este blog sencillo voy a mostrar parte de la configuracion y como se ven los test cuando usamos node + express, en una aplicacion con typescript y jest.

El repositorio que estoy utilizando es un proyecto random que tengo waifuland

Configuracion ๐Ÿงฐ

En caso que llegue a ser JS solamente la configuracion es mas simple.

โ”ฃ ๐Ÿ“„ .env
โ”ฃ ๐Ÿ“„ .gitignore
โ”ฃ ๐Ÿ“„ jest.config.js
โ”ฃ ๐Ÿ“„ jest.setup.js
โ”ฃ ๐Ÿ“„ package-lock.json
โ”ฃ ๐Ÿ“„ package.json
โ”ฃ ๐Ÿ“„ tsconfig.build.json
โ”— ๐Ÿ“„ tsconfig.json

El testMatch ajustenlo a su necesidad, si no usan TS pueden quitar el transform.

// jest.config.js
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
  clearMocks: true,
  coverageProvider: "v8",
  moduleFileExtensions: ["js", "jsx", "ts", "tsx", "json", "node"],
  setupFilesAfterEnv: ["<rootDir>/jest.setup.js"],
  roots: ["<rootDir>/src"],
  testMatch: ["**/__tests__/(unit|integration)/*.[jt]s?(x)", "**/?(*.)+(spec|test).[tj]s?(x)"],
  transform: {
    "^.+\\.(ts|tsx)$": "ts-jest",
  },
};
// jest.setup.js
jest.setTimeout(30000);

Dependencias ๐ŸŸ

ts-jest No es necesario si solo se usa Javascript.

"scripts": {
    "test": "jest",
    "test:coverage": "jest --coverage"
  },
"devDependencies": {
    "@jest/globals": "^29.7.0",
    "@types/jest": "^29.5.11",
    "@types/node": "^17.0.8",
    "@types/supertest": "^6.0.2",
    "jest": "^29.7.0",
    "supertest": "^6.3.4",
    "ts-jest": "^29.1.1",
    "typescript": "^5.3.3"
  }

Pipeline ๐Ÿงช

Los env no es necesario si no tienen datos sensibles, yo por que tengo el connection string de la base de datos y mis credenciales de rollbar.

name: CI/CD

on:
  workflow_dispatch:
  push:
    branches:
      - master
      - development
    paths:
      - 'src/**'
      - 'Dockerfile'
  pull_request:
    branches:
      - master
      - development
    paths:
      - 'src/**'
      - 'Dockerfile'

env:
  BRANCH_NAME: ${{ github.ref_name }}

jobs:
  gitleaks:
    uses: jd-apprentice/jd-workflows/.github/workflows/gitleaks.yml@main
    with:
      runs-on: ubuntu-latest
      name: Gitleaks
    secrets:
      gh_token: ${{ secrets.GITHUB_TOKEN }}

  test:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '18'

      - run: npm install
      - name: Run tests
        run: |
          touch .env
          echo DB_HOST="${{ secrets.DB_HOST }}" >> .env
          echo PORT=3000 >> .env
          echo NODE_ENV=TEST >> .env
          echo ROLLBAR_TOKEN="${{ secrets.ROLLBAR_TOKEN }}" >> .env
          echo ROLLBAR_ENVIRONMENT=development >> .env
          npm run test

      - uses: ArtiomTr/jest-coverage-report-action@v2.3.0

Tipos de TEST ๐Ÿงช

Aca les voy a dejar ejemplos de test UNITARIOS y de INTEGRACION, la idea de los unitarios es mockear o simular los diferentes metodos, funciones y demas. Sin probar la integracion completa, se suelen harcodear datos tanto de input como output si es necesario.

Ejemplo de Unitario ๐Ÿ“ฐ

๐ŸŒณ unit/
โ”ฃ ๐Ÿ“ mocks/
โ”ƒ โ”— ๐Ÿ“„ image.ts
โ”— ๐Ÿ“„ images.test.ts

El mock este es un ejemplo de una de mis apps

// mocks/image.ts
export const image = {
    id: expect.any(String),
    url: expect.any(String),
    is_nsfw: expect.any(Boolean),
    tag: {
        name: expect.any(String),
        tag_id: expect.any(Number),
        description: expect.any(String),
        is_nsfw: expect.any(Boolean),
        createdAt: expect.any(String),
        updatedAt: expect.any(String),
        is_active: expect.any(Boolean)
    }
};

const defaultSize = 1;
const defaultId = '1';
const defaultUrl = 'https://www.example.com/image.jpg';
const defaultNsfw = false;
const defaultTag = {
    name: 'tag',
    tag_id: 1,
    description: 'description',
    is_nsfw: false,
    createdAt: '2021-01-01',
    updatedAt: '2021-01-01',
    is_active: true
};

const defaultImage = {
    id: defaultId,
    url: defaultUrl,
    is_nsfw: defaultNsfw,
    tag: defaultTag
};

export const getImageMock = jest.fn((args = {
    size: defaultSize,
    tag_id: defaultTag.tag_id
}) => {

    let response = Array.from({ length: args.size }, () => defaultImage);

    if (args.tag_id) {
        response = response.map((image) => {
            return {
                ...image,
                tag: {
                    ...image.tag,
                    tag_id: args.tag_id,
                    name: expect.any(String),
                    description: expect.any(String)
                }
            };
        });
    };

    return args.size === 1 ? response[0] : response;
});
// image.test.ts
import { ImageProp } from "../../interfaces/image-interface";
import { getImageMock, image } from "./mocks/image";

describe("UNIT - Images Module", () => {

    beforeAll(async () => {
        jest.resetModules();
    });

    test('GET /api/images - when asking for an image should return a random one', async () => {
        const response = getImageMock({ size: 1 });
        expect(getImageMock).toHaveBeenCalledTimes(1);
        expect(response).toMatchObject(image);
    });

    test('GET /api/images?size=2 - when asking for 2 images should return an array of 2 images', async () => {
        const response = getImageMock({ size: 2 });
        expect(getImageMock).toHaveBeenCalledTimes(1);
        expect(response).toEqual([image, image]);
    });

    test('GET /api/images?size=1&tag_id=2 - when asking for a tag_id should return that one', async () => {
        const response = getImageMock({ size: 1, tag_id: 2 }) as ImageProp;
        expect(getImageMock).toHaveBeenCalledTimes(1);
        expect(response.tag.tag_id).toBe(2);
    });

    test("GET /api/images/all - when asking for all images should return an array of images", async () => {
        const response = getImageMock({ size: 4 }) as ImageProp[];
        expect(getImageMock).toHaveBeenCalledTimes(1);
        expect(response.length).toBe(4);
    });

});

Ejemplo de Integracion ๐Ÿ“ฐ

Los test de INTEGRACION son los que prueban algo entero, ej un controlador, como usamos supertest para esto lo que hacemos es hacer llamados HTTP, levantando la app por eso cargo la DB, de ahi le voy haciendo request reales, asi que no hagan esto en PROD, solamente en entornos bajos.

import { loadDatabase } from "../../../app/db";
import Config from "../../../app/config/config";
import { app } from "../../../app/main";

import request from 'supertest';
import { Response } from 'supertest';
import { Server } from 'http';

const baseRoute = '/api/images';
const contentTypeKey = 'Content-Type';
const contentTypeValue = /json/;
const httpSuccess = 200;

export const image = {
    id: expect.any(String),
    url: expect.any(String),
    is_nsfw: expect.any(Boolean),
    tag: {
        name: expect.any(String),
        tag_id: expect.any(Number),
        description: expect.any(String),
        is_nsfw: expect.any(Boolean),
        createdAt: expect.any(String),
        updatedAt: expect.any(String),
        is_active: expect.any(Boolean)
    }
}

describe("INTEGRATION - Images Module", () => {

    let server: Server;

    beforeAll(async () => {
        jest.resetModules();
    });

    beforeEach(async () => {
        server = app.listen(Config.app.port);
        await loadDatabase(Config.db.uri);
    })

    test('GET /api/images - when asking for an image should return a random one', async () => {
        await request(app)
            .get(baseRoute)
            .expect(contentTypeKey, contentTypeValue)
            .expect(httpSuccess)
            .then((response) => expectImage(response));
    });
}

function expectImage(response: Response) {
    expect(response.body).toBeTruthy();
    expect(response.body).toMatchObject(image);
}

Resultados ๐Ÿ

Esta es mi pipeline que incluye gitleaks, jest, codacy y dockerhub

ci/cd

La extension para coverage, en caso que quieran despues hacerlo bloqueante

coverage

Demostracion de como corren los TEST

pipeline

Did you find this article valuable?

Support Jonathan by becoming a sponsor. Any amount is appreciated!

ย