Back to Node II: Testing

Featured image

Part 2 in my quest to write a URL shortener in Typescript. If you haven’t read it, Part 1 is here.

Update: Part 3 and Part 4 are now available.

Before I got too far down the rabbit hole of sorting out how to use TypeORM I wanted to create tests I could use to see if my code was actually working.

ID to String

Let’s get this out of the way, I wouldn’t do this if I was writing a URL shortener to be used in anger. I’m using this method given I will be the only one generating shortened URLs. With that out of the way…

I’m going to use an auto-incrementing integer field for each shortened URL. To make it look cooler in a URL I’ll map this to a string using the pattern [a-z]{4} which will give me 456,976 URLs. That should keep me going for a while.

Before writing the code I wrote the tests.

import { generateUrlId } from './urlIds';

test('generateUrlId throws error with numbers out of range', () => {
  expect(() => {
    generateUrlId(-128);
  }).toThrowError();
  expect(() => {
    generateUrlId(-1);
  }).toThrowError();
  expect(() => {
    generateUrlId(26 ** 4 + 1);
  }).toThrowError();
});

test('URL IDs are generated correctly', () => {
  expect(generateUrlId(0)).toBe('aaaa');
  expect(generateUrlId(1)).toBe('aaab');
  expect(generateUrlId(25)).toBe('aaaz');
  expect(generateUrlId(26)).toBe('aaba');
  expect(generateUrlId(52)).toBe('aaca');
  expect(generateUrlId(100)).toBe('aadw');
  expect(generateUrlId(26 ** 4 - 1)).toBe('zzzz');
});

It took me longer than I expected to write the code to fit these tests but eventually I got there.

Now I can generate an ID, I need to start writing code to generate shortened URLs.

Creating Shortened URLs

interface IShortenedUrl {
  id: string;       // The generated ID e.g. aaab
  original: string; // The original URL (e.g. https://www.google.com)
  short: string;    // The shortened URL (e.g. https://short.com/aaab)
  created: Date;
  lastAccessed: Date;
}

I wrote the tests below to verify:

  • A new shortened URL can be created (returns a 2xx response)
  • The returned JSON conforms to the IShortenedUrl interface (shown above)
  • The created and lastAccessed times are within a 10 second buffer of the client’s time
  • The shortened URL redirects to the original URL

By writing these tests first then writing the code I uncovered two bugs that would have been annoying to find out later on:

  • Initially my dates were being stored in local time, not UTC. This meant all my created dates were being returned as ten hours before they were actually created.
  • I needed to pass in the BASE_URL of the server as an environment variable. Initially I had hardcoded the hostname I had intended to use but this meant I couldn’t properly test the server.

Thanks tests!

test('Can create a shortened URL', async () => {
  const body = { url: 'https://www.google.com/' };
  const requestStartTime = new Date();

  const response = await fetch(`${API}/url`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });

  const requestEndTime = new Date();
  expect(response.ok).toEqual(true);

  const shortenedUrl: IShortenedUrl = await response.json();
  shortenedUrl.created = new Date(shortenedUrl.created);
  shortenedUrl.lastAccessed = new Date(shortenedUrl.lastAccessed);

  // Verify the URL ends with the ID
  const urlId = shortenedUrl.short.split('/').slice(-1)[0];
  expect(urlId).toBe(shortenedUrl.id);

  // Verify the original URL matches what we passed in
  expect(shortenedUrl.original).toBe(body.url);

  // Verify the date retruned from the server is within a 10 second buffer of the client's time
  expect(shortenedUrl.created.getTime()).toBeGreaterThan(requestStartTime.getTime() - 5000);
  expect(shortenedUrl.created.getTime()).toBeLessThan(requestEndTime.getTime() + 5000);

  expect(shortenedUrl.lastAccessed.getTime()).toBeGreaterThan(requestStartTime.getTime() - 5000);
  expect(shortenedUrl.lastAccessed.getTime()).toBeLessThan(requestEndTime.getTime() + 5000);

  // Verify accessing the URL gives a 302 to the right URL
  const shortResponse = await fetch(shortenedUrl.short);
  expect(shortResponse.ok).toEqual(true);
  expect(shortResponse.redirected).toBe(true);
  expect(shortResponse.url).toBe(body.url);
});

Pulling It All Together With Docker Compose

In order to test this end to end I needed to spin up the following environment:

  • A Postgres DB
  • An instance of the App
  • A container to run the tests in

Docker Compose is perfect for this. The compose file I’m using is shown below.

version: '3.1'

services:
  db:
    image: postgres:12.4-alpine
    environment:
      POSTGRES_USER: shorten
      POSTGRES_PASSWORD: shorten
      POSTGRES_DB: shorten
    expose:
      - 5432

  shorten:
    build: .
    image: shorten
    environment:
      DB_USERNAME: shorten
      DB_HOST: db
      DB_PASSWORD: shorten
      DB_NAME: shorten
      BASE_URL: http://shorten:8000
    expose:
      - 8000
    depends_on:
      - db

  shorten-tests:
    build:
      context: .
      dockerfile: Dockerfile-test
    entrypoint: npx jest test
    environment:
      APP_URL: http://shorten:8000
    depends_on:
      - db
      - shorten

And my Dockerfiles look like this:

Dockerfile

FROM node:14.8-alpine AS build

WORKDIR /usr/src/app

COPY package*.json ./
COPY tsconfig.json .
COPY src ./src

RUN npm install && npm run build
RUN npm ci --production

FROM node:14.8-alpine
WORKDIR /usr/src/app

COPY --from=build /usr/src/app/node_modules ./node_modules
COPY --from=build /usr/src/app/dist ./
COPY package.json ./

ENTRYPOINT [ "node", "index.js" ]

Dockerfile-test

FROM node:14.8-alpine AS build

WORKDIR /usr/src/app

COPY package*.json ./
COPY tsconfig.json .
COPY jest.config.js .
COPY src ./src

RUN npm install

ENTRYPOINT ["npm", "run", "test"]

I can then run my tests with the following command. This will rebuild the containers each time it’s run and exit as soon as the test container exits. If the tests fail, the docker-compose command will exit with a non-zero exit status—perfect to use in a CI/CD pipeline.

docker-compose -f docker-compose-test.yml up \
    --abort-on-container-exit \
    --build

If it all works, I get output like below. Completely surprising, this actually worked the first time I ran it. Was not expecting that…

shorten-tests_1  | PASS src/urlstats.test.ts (7.69 s)
shorten-tests_1  | PASS src/urlIds.test.ts (7.791 s)
shorten-tests_1  | PASS src/__tests__/apiTest.ts (12.357 s)
shorten-tests_1  | 
shorten-tests_1  | Test Suites: 3 passed, 3 total
shorten-tests_1  | Tests:       7 passed, 7 total
shorten-tests_1  | Snapshots:   0 total
shorten-tests_1  | Time:        14.301 s
shorten-tests_1  | Ran all test suites matching /test/i.
ziz-shortener_shorten-tests_1 exited with code 0

Now I can test my integration from end-to-end in a repeatable way. Next step is to set up GitHub actions to run this on each build. Stay tuned for Part 3.