Setting Up
In this tutorial we'll walk through setting up an Autometa based API testing framework in a Typescript project. The goal of this guide is to end up with a project that can run tests against a local server, or a live deployed service.
From Autometa we'll use the following libraries:
@autometa/runner
- Our Cucumber executor, which contains a HTTP Client built on Axios@autometa/status-codes
- A collection of HTTP status codes and their descriptions which can be used for assertions.@autometa/builder
- Simply define and create DTOs and Request obects using a builder pattern.
We'll also use some other great libraries:
- MyZod, Zod or other schema
validation library you like to validate our API responses and reduce our 'Testing Surface Area'
- Schemas let us validate the "shape" of a response. e.g is
name
astring
anddetails
anobject
? - Validators besides MyZod and Zod may require you to write a wrapper to interact with the Autometa HTTP Client, if in use.
- Schemas let us validate the "shape" of a response. e.g is
- Envalid a schema validator for the
environment variables in our project. You could also make your own with MyZod or Zod.
- This will parse our
process.env
including values injected from CI Workflows or.env
files. - Handles type conversion from
string
(only type supported byprocess.env
) tonumber
,boolean
,object
etc.
- This will parse our
- DotEnv to load our environment variables from a
.env
file- We'll store our API urls and other environment-based or sensitive data in this file (do not commit this file to source control)
- Reflect Metadata - To allow us to use decorators in Typescript
- This requires
experimentalDecorators
andemitDecoratorMetadata
to be set totrue
in yourtsconfig.json
- This requires
- Jest - At present, Autometa requires Jest as a Test Runner. Future support for Vitest is planned.
- ts-jest - To allow Jest to run Typescript tests
- ts-node - To allow Jest to run Typescript tests
Optional:
If you prefer the workflow of Axios or node-fetch you can use that as a HTTP client instead.
This library requires experimentalDecorators
and emitDecoratorMetadata
to be set to true
in your tsconfig.json
Pre Setup
To begin, set up a new Typescript project to your preferred configuration. If starting from scratch, you can use a project template such as this.
Configure your jest.config & tsconfig to your liking and install the following dependencies:
npm i -D @autometa/runner @autometa/status-codes @autometa/builder myzod envalid dotenv
Our API
For this example we're going to build a framework to test the free Dummy JSON API.
Choose Your test Style
Autometa supports two styles of testing:
- Gherkin
- Uses the
@autometa/jest-transformer
to execute Cucumber.feature
files directly in Jest. - Test Scenarios are assembled automatically by globally defined
Step Definitions
andHooks
.
- Uses the
- Jest-Cucumber Inspired
- Executes code files which reference
.feature
files, and support nested Step Definitions, and concrete test scenarios.
- Executes code files which reference
For this tutorial we'll use Gherkin
style.
Create a Config file
In the Root of your project, create a file called autometa.config.ts
and add the following:
import { defineConfig } from "@autometa/runner";
export default defineConfig();
Next we need to add the following options:
- runner - The Library or Framework running our tests
- Currently only
jest
is supported
- Currently only
- roots - roots define the roots for important files in our project.
features
- The root of our.feature
files relative to the project root.- e.g.
$root/integration/features
- e.g.
- steps: The root of our
Step Definitions
relative to the project root.- e.g.
$root/integration/steps
- e.g.
- Step Definitions act as "import with side effects". This option is required to ensure they are loaded.\
- app: The 'App' is the entry point for our tests and a deviation from Cucumbers default "World" concept. It is the file that will be executed by our test runner.
- e.g.
$root/integration/app.ts
- A class
App
contains our supporting and utility classes via dependency injection. - A class
World
contains our test state and is passed to each test scenario. Almost identitical to Cucumber'sWorld
concept. - (recommended) a
env.ts
file to contain our environment variables and their types using Envalid.
- e.g.
- (Optional) shim - an object which can enable or disable shims. Currently only 'Error Causes' is supported.
- Error Causes - Errors in jest will contain a stack of errors, not just a textual stack trace.
- Not necessary for ecmascript 2022+ as it's now supported natively
import { defineConfig } from "@autometa/runner";
export default defineConfig({
runner: "jest",
roots: {
features: "integration/features",
steps: "integration/steps",
app: "src/app.ts"
},
shim: {
errorCauses: true
}
});
Make sure your autometa.config.ts
is included in your tsconfig file
under include
or files
:
{
"include": ["autometa.config.ts"]
}
App
The App
is our central point of communication within a test. An App
is a class you define in your Framework
using the @AppType
decorator. AppType
takes a World
argument. This is reference to your World
class.
In the official Cucumber implementation, there is no App
concept. Instead, Cucumber relies on implicit
access to the World
object through the this
variable. As a result, Cucumber functions must be defined with
function(){}
syntax, and cannot use (fat)=> 'arrow'
functions.
In Cucumber this
is bound to the tests World
object.
In Autometa the World
is defined explicitly, and it is a child of the App
. Conceptually,
the World
is a state manager. It acts as a way of passing state between steps within a
Scenario's Step Definitions. It is unique between tests but shared between steps.
The App
is a wrapper over the world, which represents state, and other fixtures which handle behavior,
such as HTTP Clients, Database Clients, Page Objects or other utilities.
We can add dependencies to the app by creating classes marked with @Fixture
:
// my-client.ts
import { Fixture, Constructor HTTP } from "@autometa/runner";
@Fixture
@Constructor(HTTP)
export class MyClient {
constructor(readonly http: HTTP) {
this.http.url(Env.API_URL);
}
async getResource() {
return await this.http.route("myResource").get();
}
}
// app.ts
import { AppType } from "@autometa/runner";
import { MyClient } from "./my-client";
import { World } from "./world";
@AppType(World)
export class App {
constructor(readonly myClient: MyClient) {}
}
HTTP
is a built in HTTP client which wraps Axios
.
The app will be instantiated once per test and will contain a test-specific reference to a World instance.
import { Given } from "@autometa/runner";
import { App } from "../app";
When("I retrieve the resource", ({ world, myClient }: App) => {
world.response = await myClient.getResource();
});
It is not necessary to explicitely define the paramater as : APP
provided you follow the steps Declaration Overrides
below. With overrides, the App is inferred and the World with it
World
World is a Key:Value store represented by a blank class instance. It is automatically injected into the App during tests.
Values in the world will persist between
- Before Hooks
- After Hooks
- Scenarion and Scenario Outline Step Definitions
- Background Step Definitions
Meaning you can set up data in a pretest hook and use it as seed data for your tests. We can declare values on the world which are undefined by default, but are available with their types until the value is filled.
import { Fixture, HTTPResponse } from "@autometa/runner";
import { MyResourceBody } from "./myclient/myclient.types.ts";
@Fixture
export class World {
declare myResourceResponse: HTTPResponse<MyResourceBody>;
}
Env
To set up our environment variables we'll use Envalid
and DotEnv
.
Create a .env
file in the root of your project and add the following:
API_URL=https://dummyapi.io/data/api
Next create a file env.ts
in ./src/env
of your project and add the following:
import { cleanEnv, str } from "envalid";
import { config } from "dotenv";
config();
export const Env = cleanEnv(process.env, {
API_URL: str()
});
Here we defined an API_URL
which points to our API. This can easily be configured from .env
files,
or CI/CD workflows.
// some-file.ts
const url = Env.API_URL;
Declaration Overrides
Your App
/World
classes are unique. No other project has one quite like yours. It's important then
that it be declared in a way that is unique to your project. To do this, we'll use a declaration override
.
Create a new directory __typings__
and include it as a typeRoot
in your tsconfig.json
:
Now that we have our App and World defined, we can declare them to override Autometas empty default interfaces.
{
"compilerOptions": {
"typeRoots": ["./__typings__"]
}
}
Next create autometa.d.ts
, and override Autometas internal App
and World
interfaces with your own:
import { App as MyApp, World as MyWorld } from "../src/app";
declare module "@autometa/runner" {
interface App extends MyApp {}
interface World extends MyWorld {}
}
Steps will now automatically infer the type of the App and World, and it is no longer necessary to explicitely define their type in your Step Definitions.
Given("I have a world", ({ world }) => {
// world is inferred as MyWorld
});
We can also use these overridden to automatically infer the type of Cucumber Expressions.
Given("a {builder:product} to add", (product, { world }) => {
// product is inferred as ProductBuilder
// world is inferred as MyWorld
world.productBuilder = product;
});
// src/app/types.ts
interface Types {
"builder:product": ProductBuilder;
}
import type { App as MyApp, World as MyWorld } from "../src/app";
import type { ProductBuilder } from "../src/product/product.builder";
import type { Types as T } from '@autometa/runner';
declare module "@autometa/runner" {
interface App extends MyApp {}
interface World extends MyWorld {}
interface Types extends T {}
}
Use the export * from './foo'
syntax in index.ts
files to make import your modules more easily.