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:
- Tests for your domain code. We recommend Domain Driven Design.
- Tests for your APIs, the endpoints handling requests.
- 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
:
"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
- yarn
- pnpm
npm test
yarn test
pnpm 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
:
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 appSST_STAGE
with the stage- It fetches all SSM Parameters prefixed with
/sst/{appName}/{stageName}/*
, and sets the environment variables prefixed withSST_*
. 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.