Custom Contract and End-to-End Tests with Arazzo
Arazzo is a simple, human-readable, and extensible specification for defining API workflows. Arazzo powers test generation, allowing you to define custom tests for any use case and define rich tests capable of:
- Testing multiple operations.
- Testing different inputs.
- Validating the correct response is returned.
- Run against a real API or mock server.
- Configure setup and teardown routines for complex E2E tests.
The Arazzo Specification allows you to define sequences of API operations and their dependencies for contract testing, enabling you to validate that your API behaves correctly across multiple interconnected endpoints and complex workflows.
When a .speakeasy/tests.arazzo.yaml
file is found in your SDK repo, the Arazzo workflow will be used to generate tests for each of the workflows defined in the file.
Prerequisites
The following are requirements for generating tests:
- Testing feature prerequisites are met.
Test Suite Example
The following is an example Arazzo document defining 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 defined 4 steps that each feed into the next, representing the creation of a user, retrieving that user via its new ID, updating the user, and finally deleting the user. Outputs have been defined for certain steps that are then used as inputs for the following steps.
It will generate 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 a number of ways, either via inputs defined in the workflow, references from previous steps, or via values defined inline .
Workflow Inputs
Workflow inputs are a way to 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 wants to expose.
Test Generation can use any examples defined for a property in the inputs
json schemas as literal values to use as inputs for the test. As tests are none interactive, if no examples are defined, the test generation will just randomly generate values for the inputs, as it can’t ask the user for input.
arazzo: 1.0.0# ....workflows:- workflowId: some-testinputs: # This is the JSON Schema for the inputs each property in the inputs object represents a workflow inputtype: objectproperties:email:type: stringexamples:- Trystan_Crooks@hotmail.com # Examples defined will be used as literal values for the testfirstName:type: stringexamples:- TrystanlastName:type: stringexamples:- Crookssteps:- stepId: createoperationId: createUserrequestBody:contentType: application/jsonpayload: {"email": "$inputs.email", # The payload will be populated with the literal value defined in the 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 allows for the generation of tests that are more complex than a simple sequence of operations. Speakeasy’s implementation currently only allows the referencing of a previous step’s outputs, which means you will need to define what values you want 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 will be 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 will be 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 if 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 can be provided inline for parameters that matches the json schema of the parameter as defined in the associated operationrequestBody:contentType: application/jsonpayload: { # literals values that match the content type of the request body can be provided inline"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 to use, a value with interpolated Runtime Expressions or a Runtime Expression by itself.
The payload value can then be overlayed using the replacements
field which represents a list of targets within the payload to replace with the value of the replacements, which themselves can be a static valuue 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 # use the response body of the previous step as the payload for this stepreplacements: # overlay the payload with the below 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 of 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 things such as the response body to be used as values in following steps.
Current Speakeasy 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: # Outputs are a map of an output id to a runtime expression that will be used to populate the outputid: $response.body#/id # json-pointers are used to 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 are used to validate the success of the step. For test generation these will 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 will required providing binary data to test uploading or downloading files etc. In these cases test files can be provided to the test 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 will be sourced from the .speakeasy/testfiles
directory in the root of your SDK repo, where the path provided in the x-file
directive is relative to the testfiles directory.
The contents of the sourced file will be used as the value for the field being tested.
Next Steps
- Setup testing in GitHub Actions for your SDK.
- Advanced test configuration for your tests.