EARLY ACCESS
We highly recommend fully setting up SDK tests in all of your SDK repositories before moving on to exploring this.
Custom End-to-End API Contract Tests against live APIs is in an early feature set stage.
Custom End-to-End API Contract Tests with Arazzo
Speakeasy creates custom end-to-end contract tests that run against a real API.
This document explains how to write complex tests via the Arazzo specification. It also covers key configuration features for these tests such as:
- Server URLs
- Security credentials
- Environment Variable Provided Values
Arazzo is a simple, human-readable, and extensible specification for defining API workflows. Arazzo powers test generation, enabling custom tests for any use case and rich tests capable of:
- Testing multiple operations.
- Testing different inputs.
- Validating the correct response is returned.
- Running against a real API or mock server.
- Configuring setup and teardown routines for complex E2E tests.
The Arazzo Specification defines sequences of API operations and their dependencies for contract testing, validating API behavior across multiple interconnected endpoints and complex workflows.
When a .speakeasy/tests.arazzo.yaml
file exists in the SDK repo, the Arazzo workflow generates tests for each workflow defined in the file.
Prerequisites
The following are requirements for generating tests:
- Testing feature prerequisites are met.
Writing custom End-to-End tests
The following example Arazzo document defines a simple E2E test for the life cycle of a user resource in the example API.
arazzo: 1.0.0info:title: Test Suitesummary: E2E tests for the SDK and API.version: 0.0.1sourceDescriptions:- name: The APIurl: https://example.com/openapi.yamltype: openapiworkflows:- workflowId: user-lifecyclesteps:- stepId: createoperationId: createUserrequestBody:contentType: application/jsonpayload: {"email": "Trystan_Crooks@hotmail.com","first_name": "Trystan","last_name": "Crooks","age": 32,"postal_code": 94110,"metadata": {"allergies": "none","color": "red","height": 182,"weight": 77,"is_smoking": true}}successCriteria:- condition: $statusCode == 200- condition: $response.header.Content-Type == application/json- condition: $response.body#/email == Trystan_Crooks@hotmail.com- condition: $response.body#/postal_code == 94110outputs:id: $response.body#/id- stepId: getoperationId: getUserparameters:- name: idin: pathvalue: $steps.create.outputs.idsuccessCriteria:- condition: $statusCode == 200- condition: $response.header.Content-Type == application/json- condition: $response.body#/email == Trystan_Crooks@hotmail.com- condition: $response.body#/first_name == Trystan- condition: $response.body#/last_name == Crooks- condition: $response.body#/age == 32- condition: $response.body#/postal_code == 94110outputs:user: $response.bodyage: $response.body#/age- stepId: updateoperationId: updateUserparameters:- name: idin: pathvalue: $steps.create.outputs.idrequestBody:contentType: application/jsonpayload: $steps.get.outputs.userreplacements:- target: /postal_codevalue: 94107- target: /agevalue: $steps.get.outputs.agesuccessCriteria:- condition: $statusCode == 200- condition: $response.header.Content-Type == application/json- condition: $response.body#/email == Trystan_Crooks@hotmail.com- condition: $response.body#/first_name == Trystan- condition: $response.body#/last_name == Crooks- condition: $response.body#/age == 32- condition: $response.body#/postal_code == 94107outputs:email: $response.body#/emailfirst_name: $response.body#/first_namelast_name: $response.body#/last_namemetadata: $response.body#/metadata- stepId: updateAgainoperationId: updateUserparameters:- name: idin: pathvalue: $steps.create.outputs.idrequestBody:contentType: application/jsonpayload: {"id": "$steps.create.outputs.id","email": "$steps.update.email","first_name": "$steps.update.first_name","last_name": "$steps.update.last_name","age": 33,"postal_code": 94110,"metadata": "$steps.update.metadata"}successCriteria:- condition: $statusCode == 200- condition: $response.header.Content-Type == application/json- condition: $response.body#/email == Trystan_Crooks@hotmail.com- condition: $response.body#/first_name == Trystan- condition: $response.body#/last_name == Crooks- condition: $response.body#/age == 33- condition: $response.body#/postal_code == 94110- stepId: deleteoperationId: deleteUserparameters:- name: idin: pathvalue: $steps.create.outputs.idsuccessCriteria:- condition: $statusCode == 200
The above workflow defines 4 steps that feed into each other, representing the creation of a user, retrieving that user via its new ID, updating the user, and finally deleting the user. Outputs are defined for certain steps and then used as inputs for the following steps.
This generates the test shown below:
// src/__tests__/sdk.test.tsimport { assert, expect, it, test } from "vitest";import { SDK } from "../index.js";import { assertDefined } from "./assertions.js";import { createTestHTTPClient } from "./testclient.js";test("Sdk User Lifecycle", async () => {const sdk = new SDK({serverURL: process.env["TEST_SERVER_URL"] ?? "http://localhost:18080",httpClient: createTestHTTPClient("user-lifecycle"),});const createResult = await sdk.createUser({email: "Trystan_Crooks@hotmail.com",firstName: "Trystan",lastName: "Crooks",age: 32,postalCode: "94110",metadata: {allergies: "none",additionalProperties: {"color": "red","height": "182","weight": "77","is_smoking": "true",},},});expect(createResult.httpMeta.response.status).toBe(200);expect(createResult.user?.email).toEqual("Trystan_Crooks@hotmail.com");expect(createResult.user?.postalCode).toBeDefined();expect(createResult.user?.postalCode).toEqual("94110");const getResult = await sdk.getUser(assertDefined(createResult.user?.id));expect(getResult.httpMeta.response.status).toBe(200);expect(getResult.user?.email).toEqual("Trystan_Crooks@hotmail.com");expect(getResult.user?.firstName).toBeDefined();expect(getResult.user?.firstName).toEqual("Trystan");expect(getResult.user?.lastName).toBeDefined();expect(getResult.user?.lastName).toEqual("Crooks");expect(getResult.user?.age).toBeDefined();expect(getResult.user?.age).toEqual(32);expect(getResult.user?.postalCode).toBeDefined();expect(getResult.user?.postalCode).toEqual("94110");const user = assertDefined(getResult.user);user.postalCode = "94107";user.age = getResult.user?.age;const updateResult = await sdk.updateUser(assertDefined(createResult.user?.id),assertDefined(getResult.user),);expect(updateResult.httpMeta.response.status).toBe(200);expect(updateResult.user?.email).toEqual("Trystan_Crooks@hotmail.com");expect(updateResult.user?.firstName).toBeDefined();expect(updateResult.user?.firstName).toEqual("Trystan");expect(updateResult.user?.lastName).toBeDefined();expect(updateResult.user?.lastName).toEqual("Crooks");expect(updateResult.user?.age).toBeDefined();expect(updateResult.user?.age).toEqual(32);expect(updateResult.user?.postalCode).toBeDefined();expect(updateResult.user?.postalCode).toEqual("94107");const updateAgainResult = await sdk.updateUser(assertDefined(createResult.user?.id),{id: assertDefined(createResult.user?.id),email: assertDefined(updateResult.user?.email),firstName: updateResult.user?.firstName,lastName: updateResult.user?.lastName,age: 33,postalCode: "94110",metadata: updateResult.user?.metadata,},);expect(updateAgainResult.httpMeta.response.status).toBe(200);expect(updateAgainResult.user?.email).toEqual("Trystan_Crooks@hotmail.com");expect(updateAgainResult.user?.firstName).toBeDefined();expect(updateAgainResult.user?.firstName).toEqual("Trystan");expect(updateAgainResult.user?.lastName).toBeDefined();expect(updateAgainResult.user?.lastName).toEqual("Crooks");expect(updateAgainResult.user?.age).toBeDefined();expect(updateAgainResult.user?.age).toEqual(33);expect(updateAgainResult.user?.postalCode).toBeDefined();expect(updateAgainResult.user?.postalCode).toEqual("94110");const deleteResult = await sdk.deleteUser(assertDefined(createResult.user?.id),);expect(deleteResult.httpMeta.response.status).toBe(200);});
Input and Outputs
Inputs
Inputs can be provided to steps in several ways: via inputs defined in the workflow, references from previous steps, or via values defined inline.
Workflow Inputs
Workflow inputs provide input parameters to the workflow that can be used by any step defined in the workflow. The inputs
field of a workflow is a JSON Schema object that defines a property for each input the workflow exposes.
Test Generation uses any examples defined for a property in the inputs
json schemas as literal values for the test. Since tests are non-interactive, if no examples are defined, the test generation randomly generates values for the inputs.
arazzo: 1.0.0# ....workflows:- workflowId: some-testinputs: # JSON Schema for the inputs where each property represents a workflow inputtype: objectproperties:email:type: stringexamples:- Trystan_Crooks@hotmail.com # Examples are used as literal values for the testfirstName:type: stringexamples:- TrystanlastName:type: stringexamples:- Crookssteps:- stepId: createoperationId: createUserrequestBody:contentType: application/jsonpayload: {"email": "$inputs.email", # Payload populated with the literal value from inputs"first_name": "$inputs.firstName","last_name": "$inputs.lastName",}successCriteria:- condition: $statusCode == 200
Step References
Parameters and request body payloads can reference values via Runtime Expressions from previous steps in the workflow. This enables generation of tests that are more complex than a simple sequence of operations. Speakeasy’s implementation currently only allows referencing a previous step’s outputs, requiring definition of values to expose to future steps.
arazzo: 1.0.0# ....workflows:- workflowId: some-teststeps:- stepId: createoperationId: createUserrequestBody:contentType: application/jsonpayload: #....successCriteria:- condition: $statusCode == 200- condition: $response.header.Content-Type == application/json- condition: $response.body#/email == Trystan_Crooks@hotmail.comoutputs:id: $response.body#/id # The id field of the response body exposed as an output for the next step- stepId: getoperationId: getUserparameters:- name: idin: pathvalue: $steps.create.outputs.id # The id output from the previous step used as the value for the id parametersuccessCriteria:- condition: $statusCode == 200
Inline Values
For any parameters or request body payloads a step defines, literal values can be provided inline to populate the tests when static values are suitable for the test.
arazzo: 1.0.0# ....workflows:- workflowId: some-teststeps:- stepId: updateoperationId: updateUserparameters:- name: idin: pathvalue: "some-test-id" # A literal value for parameters that matches the json schema of the parameter as defined in the associated operationrequestBody:contentType: application/jsonpayload: { # Literal values that match the content type of the request body"email": "Trystan_Crooks@hotmail.com","first_name": "Trystan","last_name": "Crooks","age": 32,"postal_code": 94110,"metadata": {"allergies": "none","color": "red","height": 182,"weight": 77,"is_smoking": true}}successCriteria:- condition: $statusCode == 200
Payload Values
When using the payload
field of a request body input, the value can be a static value, a value with interpolated Runtime Expressions or a Runtime Expression by itself.
The payload value can be overlayed using the replacements
field which represents a list of targets within the payload to replace with the value of the replacements, which can be a static value or a Runtime Expression.
arazzo: 1.0.0# ....workflows:- workflowId: some-teststeps:- stepId: get# ...outputs:user: $response.body- stepId: updateoperationId: updateUserparameters:- name: idin: pathvalue: "some-test-id"requestBody:contentType: application/jsonpayload: $steps.get.outputs.user # Response body of the previous step used as the payloadreplacements: # Overlay the payload with replacements- target: /postal_code # Overlays the postal_code field with a static valuevalue: 94107- target: /age # Overlays the age field with the value of the age output from a previous stepvalue: $steps.some-other-step.outputs.agesuccessCriteria:- condition: $statusCode == 200
Outputs
As shown above, outputs can be defined for each step in a workflow allowing values from the response body to be used as values in following steps.
Current Speakeasy implementation supports only referencing values from a response body, using the Runtime Expressions syntax and json-pointers.
Any number of outputs can be defined for a step.
arazzo: 1.0.0# ....workflows:- workflowId: some-teststeps:- stepId: createoperationId: createUserrequestBody:contentType: application/jsonpayload: #....successCriteria:- condition: $statusCode == 200- condition: $response.header.Content-Type == application/json- condition: $response.body#/email == Trystan_Crooks@hotmail.comoutputs: # Map of output id to runtime expression that populates the outputid: $response.body#/id # Json-pointers reference fields within the response bodyemail: $response.body#/emailage: $response.body#/ageallergies: $response.body#/metadata/allergies
Success Criteria
The successCriteria
field of a step is a list of Criterion Objects that validate the success of the step. For test generation these form the basis of the test assertions.
successCriteria
can be as simple as a single condition testing the status code of the response, or as complex as testing multiple individual fields within the response body.
Speakeasy’s implementation currently only supports simple
criteria and the equality operator ==
for comparing values, and testing status codes, response headers and response bodies.
For testing values within the response body, criteria for testing the status code and content type of the response are also required to help the generator determine which response schema to validate against due to the typed nature of the SDKs.
arazzo: 1.0.0# ....workflows:- workflowId: some-teststeps:- stepId: createoperationId: createUserrequestBody:contentType: application/jsonpayload: #....successCriteria:- condition: $statusCode == 200- condition: $response.header.Content-Type == application/json- condition: $response.body#/email == Trystan_Crooks@hotmail.com# or- context: $response.bodytype: simplecondition: |{"email": "Trystan_Crooks@hotmail.com","first_name": "Trystan","last_name": "Crooks","age": 32,"postal_code": 94110,"metadata": {"allergies": "none","color": "red","height": 182,"weight": 77,"is_smoking": true}}
Testing operations requiring binary data
Some operations require providing binary data to test uploading or downloading files. In these cases test files can be provided using the x-file
directive in the example for that field.
arazzo: 1.0.0# ....workflows:- workflowId: postFilesteps:- stepId: testoperationId: postFilerequestBody:contentType: multipart/form-datapayload:file: "x-file: some-test-file.txt"successCriteria:- condition: $statusCode == 200- condition: $response.header.Content-Type == application/octet-stream- context: $response.bodycondition: "x-file: some-other-test-file.dat"type: simple
The files are sourced from the .speakeasy/testfiles
directory in the root of the SDK repo, where the path provided in the x-file
directive is relative to the testfiles directory.
The contents of the sourced file are used as the value for the field being tested.
Configuring an API to Test Against
By default, tests are generated to run against Speakeasy’s mock server (URL of http://localhost:18080
) which validates the SDKs are functioning correctly but does not guarantee the correctness of the API.
The generator can be configured to run all tests against another URL or just individual tests. This is done through the x-speakeasy-test-server
extensions in the .speakeasy/tests.arazzo.yaml
file.
If the extension is found at the top level of the Arazzo file then all workflows/tests are configured to run against the specified server URL. If the extension is found within a workflow then only that workflow is configured to run against the specified server URL.
The server URL can be either a static URL or a x-env: EXAMPLE_ENV_VAR
value that pulls the value from the environment variable EXAMPLE_ENV_VAR
where the name of the environment variable can be any specified name.
arazzo: 1.0.0# ...x-speakeasy-test-server:baseUrl: "https://api.example.com" # If specified at the top level of the Arazzo file, all workflows will be configured to run against the specified server URLworkflows:- workflowId: some-testx-speakeasy-test-server:baseUrl: "x-env: TEST_SERVER_URL" # If specified within a workflow, only that workflow will be configured to run against the specified server URL. This will override any top level configuration.# ...
A default value can be provided in the x-env
directive if the environment variable is not set. This can be useful for local development or non-production environments.
x-speakeasy-test-server:baseUrl: "x-env: TEST_SERVER_URL; http://localhost:18080" # Run against the local mock server if the environment variable is not set
If all tests are configured to run against other server URLs, mock server generation can be disabled within the .speakeasy/gen.yaml
file.
# ...generation:# ...mockServer:disabled: true # Setting this to true will disable mock server generation
Configuring security credentials for Contract tests
When running tests against a real API, the SDK may need to be configured with security credentials to authenticate with the API. This can be done by adding the x-speakeasy-test-security
extension to the document, a workflow or a individual step.
The x-speakeasy-test-security
extension allows static values or values pulled from the environment to be used when instantiating an SDK instance and making requests to the API.
arazzo: 1.0.0# ...x-speakeasy-test-security: # Defined at the top level of the Arazzo file, all workflows will be configured to use the specified security credentialsvalue:# The keys in the value map are the names of the security schemes defined in the OpenAPI document.# For simple schemes, the values is for example the API key value required.# For schemes like basic auth or OAuth2 that require multiple values, the value is a map of the required values.apiKey: x-env: TEST_API_KEY # Values can be pulled from the environmentbasicAuth:username: "test-user" # Or defined as static valuespassword: x-env: TEST_PASSWORDworkflows:- workflowId: some-testx-speakeasy-test-security: # Security can be defined/overridden for a specific workflowvalue:apiKey: "test-key"# ...steps:- stepId: step1x-speakeasy-test-security: # Or security can be defined/overridden for a specific stepvalue:authToken: x-env: TEST_AUTH_TOKEN# ...- stepId: step2# ...
Configuring environment variable provided values for Contract tests
When running tests against a real API, certain input values can be filled from dynamic environment variables using the Speakeasy environment variable extension.
arazzo: 1.0.0# ....workflows:- workflowId: my-env-var-teststeps:- stepId: updateoperationId: updateUserparameters:- name: idin: pathvalue: "x-env: TEST_ID; default" # Environment variable with optional default value if the env variable is not presentrequestBody:contentType: application/jsonpayload: {"email": "x-env: TEST_EMAIL; default, # Environment variable with optional default value if the env variable is not present"first_name": "Trystan","last_name": "Crooks","age": 32,"postal_code": 94110,"metadata": {"allergies": "none","color": "red","height": 182,"weight": 77,"is_smoking": true}}successCriteria:- condition: $statusCode == 200