Introduction to TypeScript with unit testing and continuous integration

Learn TypeScript, unit testing & CI with this guide. Boost skills, catch errors early & automate code testing with GitHub Actions. Dive in now!

Introduction to TypeScript with unit testing and continuous integration
A map filled with a pipeline. Image by Jonas Claes.

In this blog post, I'll introduce you to TypeScript, a superset of JavaScript that has helped me write more robust and maintainable code. I'll also share with you how I use unit testing and continuous integration with TypeScript to build high-quality software.

TypeScript introduces static typing and other powerful features that can make your code more reliable and easier to maintain, especially in larger projects. With TypeScript, I can catch errors early and benefit from better tooling support.

But testing your code is just as important as writing it, and that's where unit testing comes in. I'll show you how I use Jest, a popular testing framework for JavaScript, to write unit tests for my TypeScript code. You'll learn how to write effective tests and catch bugs before they cause problems.

And finally, I'll show you how to set up continuous integration using GitHub Actions. By automating the building, testing, and deployment of your code, you can catch issues early and ship software faster. I'll guide you through the process of setting up a CI pipeline, so you can test your code automatically and get fast feedback on your changes.

You can find a GitHub repository containing the below mentioned files here: jonasclaes/2023-tutorial-build-test-deploy

Whether you're new to TypeScript, unit testing, or continuous integration, this guide has something for everyone. I'm excited to share my knowledge and help you take your JavaScript development skills to the next level! So let's get started.

Project initialization

To get started, the first thing I did was create a new folder for my project. I then initialized an empty JavaScript project by running the command yarn init inside this folder. I also created a src folder to contain the source code for my project.

Next, I installed TypeScript and the Node.js types as development dependencies by running the command yarn add -D typescript @types/node. This would allow me to use TypeScript in my project and ensure that the code I write is compatible with Node.js.

Once the dependencies were installed, I created a new file called tsconfig.json in the root of my project directory with the following contents:

{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "target": "ES2020",
    "sourceMap": true,
    "outDir": "dist",
    "esModuleInterop": true
  },
  "include": [
    "src/**/*"
  ]
}
tsconfig.json

This is an optimal configuration for the small project we will be creating. It sets the module system to NodeNext which allows us to use the latest features of Node.js. It also specifies the target environment to be ES2020, enables source maps, and sets the output directory for the compiled code to dist. Finally, it enables the esModuleInterop flag which allows us to use modules that were not designed for TypeScript.

In the package.json file, I added "type": "module" to indicate to Node.js that the code we will be compiling is an ES module. This is important because it enables the use of ES module syntax such as import and export.

Finally, I added a script called build to the package.json file which runs the TypeScript compiler (tsc) to compile the TypeScript code to JavaScript. I have also added a script called start to the package.json which just runs our compiled code using Node.js. Your package.json file should look something like the following:

{
    "name": "2023-tutorial-build-test-deploy",
    "version": "1.0.0",
    "description": "A build, test and deploy tutorial using GitHub Actions.",
    "type": "module",
    "main": "dist/index.js",
    "repository": "https://github.com/jonasclaes/2023-tutorial-build-test-deploy.git",
    "author": "Jonas Claes <jonas@jonasclaes.be>",
    "license": "MIT",
    "scripts": {
        "build": "tsc",
        "start": "node dist/index.js"
    },
    "devDependencies": {
        "@types/node": "^18.13.0",
        "typescript": "^4.9.5"
    }
}
package.json

With this project initialization out of the way, we're ready to start writing TypeScript code and testing it with unit tests.

Unit testing

Unit testing is an essential part of software development that is often overlooked or misunderstood. In simple terms, unit tests are tests that are designed to test small, individual units of code, such as functions or methods. These units should be able to function on their own and have no side effects. It's important to note that unit tests should not connect to databases, interact with other services, or depend on other code.

To get started with unit testing in our project, I first added Jest and the corresponding types by running the following command: yarn add -D jest ts-jest @types/jest. Jest is a popular testing framework for JavaScript, and ts-jest is a Jest preprocessor that allows us to use TypeScript with Jest. I also added a test script to the package.json which invokes jest. Your package.json should look something like the following:

{
    "name": "2023-tutorial-build-test-deploy",
    "version": "1.0.0",
    "description": "A build, test and deploy tutorial using GitHub Actions.",
    "type": "module",
    "main": "dist/index.js",
    "repository": "https://github.com/jonasclaes/2023-tutorial-build-test-deploy.git",
    "author": "Jonas Claes <jonas@jonasclaes.be>",
    "license": "MIT",
    "scripts": {
        "build": "tsc",
        "start": "node dist/index.js",
        "test": "jest"
    },
    "devDependencies": {
        "@types/jest": "^29.4.0",
        "@types/node": "^18.13.0",
        "jest": "^29.4.3",
        "ts-jest": "^29.0.5",
        "typescript": "^4.9.5"
    }
}
package.json

Next, I generated a jest.config.js file by running ts-jest config:init. However, we needed to rename the jest.config.js file to jest.config.cjs because TypeScript needs to know what kind of JS file this is. I also created a folder called test which will contain all of our unit tests. The jest.config.cjs file should look something like the following:

/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
    preset: 'ts-jest',
    testEnvironment: 'node',
};
jest.config.cjs

Now that our test environment is set up, we can start writing our tests. To begin, I added a small piece of code to src/index.ts which we can test later on. This code exports a function called sum, which takes in two numbers and returns their sum. Here's the code:

export const sum = (a: number, b: number): number => {
    return a + b;
}
src/index.ts

After adding this code, I then created a test file at test/index.test.ts. In this file, I imported the sum function from src/index.ts and created three small unit tests to verify that it works correctly. Each test uses Jest's expect function to compare the output of the sum function to an expected value using the toBe matcher. Here's the test code:

import {sum} from "../src";

test('adds 1 + 2 to equal 3', () => {
    expect(sum(1, 2)).toBe(3);
});

test('adds 0 + 1 to equal 1', () => {
    expect(sum(0, 1)).toBe(1);
});

test('adds -1 + 1 to equal 0', () => {
    expect(sum(-1, 1)).toBe(0);
});
test/index.test.ts

These tests check that sum works correctly for different input values, ensuring that it can correctly add two numbers.

Finally, I ran yarn test to run the unit tests. If everything went okay, you should see a "PASS" message for all 3 tests in the console. By writing and running these tests, we can be more confident that our code is working correctly, and can detect and fix issues more easily as we continue to develop our project.

Automated testing

GitHub Actions is a way to automate tasks and processes that happen when you interact with your GitHub repository. In this case, we want to create a workflow to automatically test and deploy our code whenever we push new changes to the repository.

First, we create a file called test-and-deploy.yml in the .github/workflows directory. The contents of this file are written in YAML, a markup language that is often used for configuration files. This file should look something like the following:

name: Test and deploy

on:
    push:

jobs:
    Test:
        runs-on: ubuntu-latest
        steps:
            -   uses: actions/checkout@v3
            -   uses: actions/setup-node@v3
                with:
                    node-version: 16
                    cache: 'yarn'
            -   run: yarn install --frozen-lockfile # optional, --immutable
            -   name: Run tests
                run: yarn test
    Deploy:
        needs: Test
        runs-on: ubuntu-latest
        steps:
            -   uses: actions/checkout@v3
            -   uses: actions/setup-node@v3
                with:
                    node-version: 16
                    cache: 'yarn'
            -   run: yarn install --frozen-lockfile # optional, --immutable
            -   name: Compiled source code
                run: yarn build
            -   name: Zip compiled source code
                run: |
                    zip -r dist.zip dist/
            -   name: Get latest release
                uses: actions/github-script@v6
                id: get_latest_release
                with:
                    script: |
                        const { data: releases } = await github.rest.repos.listReleases({
                            owner: context.repo.owner,
                            repo: context.repo.repo,
                            per_page: 1
                        });
                        return releases[0].tag_name;
                    result-encoding: string
                env:
                    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

            -   name: Create release
                id: create_release
                uses: actions/create-release@v1
                env:
                    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
                with:
                    tag_name: ${{ steps.get_latest_release.outputs.result }}-${{ github.sha }}
                    release_name: Release ${{ steps.get_latest_release.outputs.result }}-${{ github.sha }}
                    draft: false
                    prerelease: false
            -   name: Upload release asset
                id: upload-release-asset
                uses: actions/upload-release-asset@v1
                env:
                    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
                with:
                    upload_url: ${{ steps.create_release.outputs.upload_url }}
                    asset_path: dist.zip
                    asset_name: 2023-tutorial-build-test-deploy.zip
                    asset_content_type: application/zip
.github/workflows/test-and-deploy.yml

The first line of the file specifies the name of the workflow: "Test and deploy". The on section specifies when this workflow should be triggered. In this case, we want it to be triggered whenever we push new changes to the repository.

The next section defines the Test job. A job is a series of steps that should be executed. In this case, we want to run our tests. The runs-on field specifies which operating system we want to run our tests on. In this case, we're using the latest version of Ubuntu.

The steps field is an array of steps that should be executed. The first step checks out the code from the repository. The second step sets up the environment by installing Node.js and caching our dependencies using Yarn. The third step installs the dependencies. The fourth step runs our tests.

The next section defines the Deploy job. This job should only run if the Test job succeeds (indicated by the needs field). The steps are similar to the Test job, with a few additions. First, we compile our source code using the yarn build command. Then, we create a zip file of the compiled source code. We use the actions/github-script action to get the latest release of our code, so that we can create a new release with the updated code. We create a new release using the actions/create-release action and upload the compiled source code using the actions/upload-release-asset action.

When you commit and push this file to GitHub, you should see a new action starting to run in the "Actions" tab of your repository. This action will run the Test job and then the Deploy job, if the Test job succeeds. This means that your code will be automatically tested and deployed whenever you push new changes to the repository.

Conclusion

In conclusion, automated testing is an important part of software development that ensures the code is functioning as expected and catches any errors early on in the development process. Using a CI pipeline like GitHub Actions makes it easy to automate this process, so that each time code is pushed to the repository, the tests are run automatically. This can save a lot of time and effort in the long run, as any issues can be caught and addressed before they become bigger problems. By following the steps outlined in this tutorial, you should now have a better understanding of how to set up automated testing with Jest and GitHub Actions, and how to ensure that your code is functioning as expected before it gets deployed to production.