Skip to main content

Testing

SST apps come with a great setup for writing tests.

tip

Want to learn more about testing in SST? Check out the livestream we did on YouTube.


Overview

To start, there are 3 types of tests you can write for your SST apps:

  1. Tests for your domain code. We recommend Domain Driven Design.
  2. Tests for your APIs, the endpoints handling requests.
  3. Tests for your stacks, the code that creates your infrastructure.

SST uses Vitest to help you write these tests. And it uses the sst bind CLI to bind the resources to your tests. This allows the sst/node helper library to work as if the tests were running inside a Lambda function.

info

Due to the way sst bind works, it does not support Vitest in threaded mode. We recommend disabling threads by setting the threads config option to false or by using the flag --threads=false.


Test script

If you created your app with create-sst a Vitest config is added for you, with a test script in your package.json:

package.json
"scripts": {
"dev": "sst dev",
"build": "sst build",
"deploy": "sst deploy",
"remove": "sst remove",
"console": "sst console",
"typecheck": "tsc --noEmit",
"test": "sst bind vitest run"
},

We'll look at how the sst bind CLI works a little in the chapter.

note

The sst bind CLI will join any argument that is not a flag but won't join flags. This means that sst bind vitest run path/to/test.ts is valid, but sst bind vitest run --threads=false is not! To pass in flags, wrap the command in quotes: sst bind "vitest run --threads=false".

You can now run your tests using.

npm test

Quick start

In this chapter we'll look at the different types of tests and share some tips on how to write them.

To follow along, you can create the GraphQL starter by running npx create-sst@two --example graphql/dynamo > graphql > DynamoDB. Alternatively, you can refer to this example repo based on the same template.

If you are new to the GraphQL starter, it creates a very simple Reddit clone. You can submit links and it'll display all the links that have been submitted.


Testing domain code

Open up packages/core/src/article.ts, it contains a create() function to create an article, and a list() function to list all submitted articles. This code is responsible for the article domain.

Let's write a test for our article domain code. Create a new file at packages/core/test/article.test.ts:

packages/core/test/article.test.ts
import { expect, it } from "vitest";
import { Article } from "@my-sst-app/core/article";

it("create an article", async () => {
// Create a new article
const article = await Article.create("Hello world", "https://example.com");

// List all articles
const list = await Article.list();

// Check the newly created article exists
expect(list.find((a) => a.articleID === article.articleID)).not.toBeNull();
});

Both the create() and list() functions call packages/core/src/dynamo.ts to talk to the database. And packages/core/src/dynamo.ts references Table.table.tableName.

Behind the scenes

The above test only works if we run sst bind vitest run. The sst bind CLI fetches the value for the Table.table.tableName and passes it to the test. If we run vitest run directly, we'll get an error complaining that Table.table.tableName cannot be resolved.


Testing API endpoints

We can rewrite the above test so that instead of calling Article.create(), you make a request to the GraphQL API to create the article. In fact, the GraphQL stack template already includes this test.

Open packages/functions/test/graphql/article.test.ts, you can see the test is similar to our domain function test above.

import { expect, it } from "vitest";
import { Api } from "sst/node/api";
import { createClient } from "@my-sst-app/graphql/genql";
import { Article } from "@my-sst-app/core/article";

it("create an article", async () => {
const client = createClient({
url: Api.api.url + "/graphql",
});

// Call the API to create a new article
const article = await client.mutation({
createArticle: [
{ title: "Hello world", url: "https://example.com" },
{
id: true,
},
],
});

// List all articles
const list = await Article.list();

// Check the newly created article exists
expect(
list.find((a) => a.articleID === article.createArticle.id)
).not.toBeNull();
});

Again, just like the domain test above, this only works if we run sst bind vitest run.

tip

Testing APIs are often more useful than testing Domain code because they test the app from the perspective of a user. Ignoring most of the implementation details.

Note that this test is very similar to the request frontend makes when a user tries to submit a link.


Testing infrastructure

Both domain function tests and API tests are for testing your business logic code. Stack tests on the other hand allows you to test your infrastructure settings.

Let's write a test for our Database stack to ensure that point-in-time recovery is enabled for our DynamoDB table.

Create a new file at stacks/test/Database.test.ts:

import { it } from "vitest";
import { Template } from "aws-cdk-lib/assertions";
import { initProject } from "sst/project";
import { App, getStack } from "sst/constructs";
import { Database } from "../Database";

it("point-in-time recovery is enabled", async () => {
await initProject({});
const app = new App({ mode: "deploy" });
// Create the Database stack
app.stack(Database);

// Wait for resources to finalize
await app.finish();

// Get the CloudFormation template of the stack
const stack = getStack(Database);
const template = Template.fromStack(stack);

// Check point-in-time recovery is enabled
template.hasResourceProperties("AWS::DynamoDB::Table", {
PointInTimeRecoverySpecification: {
PointInTimeRecoveryEnabled: true,
},
});
});

Note that app performs several tasks asynchronously, including building the function code and container images. It then modifies the template. To ensure you are testing against the finalized resources, call await app.finish() before running any tests.

The aws-cdk-lib/assertions import is a CDK helper library that makes it easy to test against AWS resources created inside a stack. In the test above, we are checking if there is a DynamoDB table created with PointInTimeRecoveryEnabled set to true.


Why test stacks

Like the test above, you can test for things like:

  • Are the functions running with at least 1024MB of memory?
  • Did I accidentally change the database name, and the data will be wiped out on deploy?

It allows us to ensure that our team doesn't accidentally change some infrastructure settings. It also allows you to enforce specific infrastructure policies across your app.

tip

Here are a couple of reference docs from AWS that should help you write stack tests.


Tips

Now that you know how to test various parts of your app. Here are a couple of tips on writing effective tests.


Don't test implementation details

In this chapter, we used DynamoDB as our database choice. We could've selected PostgreSQL and our tests would've remained the same.

Your tests should be unaware of things like what table data is being written, and instead just call domain functions to verify their input and output. This will minimize how often you need to rewrite your tests as the implementation details change.


Isolate tests to run them in parallel

Tests need to be structured in a way that they can be run reliably in parallel. In our domain function and API tests above, we checked to see if the created article exists:

// Check the newly created article exists
expect(
list.find((a) => a.articleID === article.createArticle.id)
).not.toBeNull();

Instead, if we had checked for the total article count, the test might fail if other tests were also creating articles.

expect(list.length).toBe(1);

The best way to isolate tests is to create separate scopes for each test. In our example, the articles are stored globally. If the articles were stored within a user's scope, you can create a new user per test. This way, tests can run in parallel without affecting each other.


How sst bind works

When testing your code, you have to ensure the testing environment has the same environment variable values as the Lambda environment. In the past, people would manually maintain a .env.test file with environment values. SST has built-in support for automatically loading the secrets and environment values that are managed by Resource Binding.

The sst bind CLI fetches all the resource values, and invokes the vitest run with the values configured as environment variables.

Behind the scenes

The sst bind CLI sets the following environment variables:

  • SST_APP with the name of your SST app
  • SST_STAGE with the stage
  • It fetches all SSM Parameters prefixed with /sst/{appName}/{stageName}/*, and sets the environment variables prefixed with SST_*. Ie. In our example above, SST_Table_tableName_table is created with value from /sst/{appName}/{stageName}/Table/table/tableName
  • For Secrets, fallback values are also fetched from SSM Parameters prefixed with /sst/{appName}/.fallback/Secret/*.
  • To do this, sst bind spawns child processes. This is why Vitest's threaded mode is not supported. Since it also spawns child processes, the combination of different threads might cause tests to fail intermittently.

This allows the sst/node helper library to work as if it was running inside a Lambda function.