Back to Node II: Testing
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
andlastAccessed
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 Dockerfile
s 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.