Estructurar el proyecto TypeScript y pruebas unitarias (3º parte – Creación de una librería TypeScript)

Publicado el 02 diciembre 2020 por Daniel Rodríguez @analyticslane

Una vez vistas las ventajas de TypeScript y cómo configurar el proyecto vamos a ver los pasos para estructurar el proyecto TypeScript. Además, en esta ocasión veremos cómo introducir pruebas unitarias con Jest, un framework que facilita la creación de pruebas que pueden ser escritas directamente en TypeScript.

Estructurar el proyecto TypeScript

En la entrada anterior definimos que el código fuente de nuestro proyecto TypeScript se almacenará en la carpeta src. Una carpeta que tendrá una estructura similar a la que usamos a la hora de crear librerías en JavaScript. Así, si migramos un proyecto existen de JavaScript a TypeScript el primer paso solo será copiar todo el código y cambiar la extensión de los archivos .js por .ts. Al ser TypeScript un superconjunto de JavaScript cualquier código del segundo es válido en el primero.

Migración de las funciones de jslane

Esto lo podemos ver si queremos migrar las funciones creadas cuando explicamos cómo crear una librería JavaScript. Librería a la que llamamos jslane. En la que todo el código se encontraba en el archivo array.js.

(function() {
  'use strict';

  const array = exports;

  // Sum of an array
  array.sum = function(arr) {
    let result = 0;

    for (let i = 0; i < arr.length; ++i) {
      result += arr[i];
    }

    return result;
  };

  // Mean of an array
  array.mean = function(arr) {
    return array.sum(arr) / arr.length;
  };

  // Summary of an array
  array.summary = function(arr) {
    return {
      sum: array.sum(arr),
      mean: array.mean(arr)
    };
  };

  // Multiply all values by a scalar
  array.multiply = function(arr, value) {
    const result = arr.slice();

    if (value === undefined) {
      return result;
    }

    for (let i = 0; i < arr.length; ++i) {
      result[i] = arr[i] * value;
    }

    return result;
  };
})();

Código que se puede traducir a TypeScript de la siguiente manera.

export interface Summary {
    sum: number
    mean: number
}

export function sum(arr: number[]): number {
    let result: number = 0;

    for (let i = 0; i < arr.length; ++i) {
        result += arr[i];
    }

    return result;
}

// Mean of an array
export function mean(arr: number[]): number {
    return sum(arr) / arr.length;
}

// Summary of an array
export function summary(arr: number[]): Summary {
    return {
        sum: sum(arr),
        mean: mean(arr)
    };
}

// Multiply all values by a scalar
export function multiply(arr: number[], value: number): number[] {
    const result = arr.slice();

    if (value === undefined) {
        return result;
    }

    for (let i = 0; i < arr.length; ++i) {
        result[i] = arr[i] * value;
    }

    return result;
}

Ahora ya no es necesario usar el modificador 'use strict'; al principio del código ni asignar las funciones a la variable exports. Lo que deja un código más limpio. Para indicar que cualquier componente del módulo se exporta simplemente se tiene que usar el modificado export antes de una función o variable, lo que hemos hecho en las cuatro funciones.

Además de estos se han indicado los tipos de datos number[] para los vectores y number para los escalares. Así, en caso de intentar pasar un tipo de variable incorrecta el compilador nos avisará en tiempo de ejecución. Así podernos ver que se puede estructurar el proyecto TypeScript se hace de una forma más sencilla que en el caso de JavaScript.

La interface Summary

En el ejemplo podemos ver algo especial, la función summary devuelve un tipo de dato que no es primitivo, es un objeto. Un objeto que podemos definir mediante una interfaz. Un elemento de TypeScript que nos permite saber exactamente qué propiedades puede tener un objeto. Así evitamos sorpresas. Sabemos los valores que se pueden pasar a un objeto y que recibimos exactamente.

Al igual que los objetos, es habitual que el nombre de las interfaces se escriba con mayúsculas al principio. La definición se crea con la palabra reservada interface seguido del nombre. A continuación, se abre corchete y se indica los elementos que pude contener un objeto que verifiqué la interfaz.

Aunque no lo hemos usado en el ejemplo, una interfaz puede tener valores opcionales. Valores que pueden o no existir. Para crear un valor opcional, simplemente se tiene que situar después del nombre y antes del tipo, el signo de interrogación ?. Por ejemplo, se podría agregar un tipo power opcional a la interfaz ya definida.

export interface Summary {
    sum: number
    mean: number
    power?:number
}

Pruebas unitarias con Jest

Ahora podemos crear pruebas unitarias para evaluar qué el código funciona correctamente. Para ello vamos a usar Jest. Una framework que ofrece varias ventajas como la posibilidad de escribir las pruebas en TypeScript e incluir la medición del grado de cobertura. En este caso necesitamos instalar las siguientes dependencias

npm install jest ts-jest @types/jest --save-dev

El primer paquete es el framework Jest, el segundo es un preprocesador para poder crear las pruebas unitarias en TypeScript en lugar de JavaScript. Finalmente, el tercero son los archivos de tipos de Jest para que los conozca el compilador de TypeScript.

module.exports = {
    globals: {
        'ts-jest': {
            tsconfig: 'tsconfig.json'
        }
    },
    moduleFileExtensions: ['ts', 'js'],
    transform: {
        '^.+\\.(ts)$': 'ts-jest'
    },
    testEnvironment: 'node'
};

Ahora necesitamos crear un archivo de configuración para Jest llamado jest.config.js y que debemos situar en la raíz del proyecto. En nuestro caso, como queremos escribir las pruebas en TypeScript, debemos indicar a ts-jest cuál es el archivo de configuración de TypeScript y la extensión de los archivos. Un archivo de configuración puede ser el siguiente.

Primera prueba con Jest

Ahora se puede crear una carpeta tests, ubicación por defecto donde busca los archivos con las pruebas Jest, y escribir la primera prueba. Por ejemplo, una para la función suma en el archivo array.test.ts. Siendo necesario usar la extensión test.ts para indicar que es una prueba unitaria.

import { sum } from '../src/array'

test('Sum the values of an array', () => {
    expect(sum([1, 2])).toBe(3);
});

En este caso se puede ver que las pruebas son similares, pero más sencillas que con Mocha. Ahora no es necesario importar por separado expect ni indicar la finalización de la prueba con la función done(). Lo que nos deja un código mucho más compacto. Para lanzar las pruebas solamente se tiene que usar el comando jest si se ha instalado la dependencia globalmente, en caso contrario tendremos que escribir npx jest. Lo que nos debería indicar que hemos pasado todas las pruebas.

Medir el grado de cobertura

Una de las ventajas de Jest sobre Mocha es que no es necesario instalar nada para lanzar las pruebas unitarias. Solamente se tiene que indicar la opción --collectCoverage para obtener estas al ejecutar las pruebas. Así, si ejecutamos el comando jest --collectCoverage veremos que en nuestro caso solamente tenemos una cobertura del 50%.

 PASS  tests/array.test.ts
  ✓ Sum the values of an array (2 ms)

----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
----------|---------|----------|---------|---------|-------------------
All files |      50 |        0 |      25 |      50 |                   
 array.ts |      50 |        0 |      25 |      50 | 18,23,31-41       
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.916 s, estimated 2 s

Los resultados tienen la misma interpretación que los del paquete nyc usado en las pruebas de JavaScript. Sería necesario comprobar las líneas que no tienen cobertura y conseguir que los niveles de esta se aproximen al 100% del código.

Excluir carpetas en la compilación

Ahora que hemos creado la carpeta de tests es aconsejable excluir esta de la compilación en el archivo tsconfig.json. Debido a que se almacenan archivos TypeScript que no es necesario compilar. Para ello se puede añadir una nueva propiedad, al mismo nivel que `compilerOptionx llamda exclude en la que indicamos las carpetas que deseamos que el compilador ignore. Actualmente debemos omitir los archivos que se encuentran tanto bajo node_modules como tests.

{
  "compilerOptions": {
    ...
  },
  "exclude": [
    "node_modules", "tests"
  ]
}

Configuración de los scripts

Ahora podremos crear tres scripts en el archivo package.json con los que compilar ( build), probar ( test) y medir el grado de cobertura ( coverage). Además es aconsejable agregar TypeScript como dependencia de desarrollo para que cuando lo distribuyamos no dependa que esta se encuentre instalada globalmente. Para lo que escribiremos en el proyecto

npm install typescript -save-dev

Y en el archivo package.json escribiremos los scripts.

  "scripts": {
    "build": "npx tsc",
    "coverage": "npx jest --collectCoverage",
    "test": "npx jest"
  },

Lo que nos puede facilitar el trabajo de depuración, aunque por el momento los comandos son bastante sencillos.

Conclusiones

Hemos visto cómo estructurar el proyecto TypeScript migrando el código que tengamos escrito en JavaScript. Además de configurar Jest como Framework de pruebas unitarias, el cual ofrece ciertas ventajas sobre Mocha como poder escribir las pruebas en TypeScript, simplificar el código y obtener el grado de cobertura con una simple opción.