@mikecbrant/appsyncjs-test-utils
TypeScript icon, indicating that this package has built-in type declarations

1.2.3 • Public • Published

@mikecbrant/appsyncjs-test-utils

This is package is intended to provide utilities suitable for conducting both unit and functional testing for AWS AppSync resolvers written in the APPSYNC_JS runtime environment.

What is APPSYNC_JS ?

The APPSYNC_JS runtime environment offers a Javascript-like set of features geared at making it easier for developers to write performant resovlers without the need to learn the legacy VTL mapping templates syntax which was formerly the primary means for implementing AppSync resolver logic.

Related reading:

The following packages are published by AWS for use in helping to develop resolvers using APPSYNC_JS (especially using TypeScript)

This library is intended to work along with @aws-appsync/utils which should be installed in your application as a dependency.

Install

For best install of this framework and full set peer dependencies use...

pnpm i -D @mikecbrant/appsyncjs-cli @mikecbrant/appsyncjs-test-utils @aws-appsync/eslint-plugin @aws-appsync/utils @aws-sdk/client-appsync vitest

# or npm
npm i -D @mikecbrant/appsyncjs-cli @mikecbrant/appsyncjs-test-utils @aws-appsync/eslint-plugin @aws-appsync/utils @aws-sdk/client-appsync vitest
  • @aws-sdk/client-appsync is peer dependency needed for evaluateFile / evaluateCode functional testing utils
  • vitest is peer dependency needed for utilMock unit testing utility
  • @aws-appsync/eslint-plugin is not needed by these libraries, but is recommended for use in linting your files for APPSYNC_JS

Unit testing resolvers

@aws-appsync/utils only provides TypeScript types for helping write resolver request and response handlers. As such, it is impossible to effectively unit test the code in such resolvers using this library alone.

Take the following example from the AWS docs and say you wanted to run a unit test on the request function to verify the returned UpdateItemRequest shape.

import { util } from '@aws-appsync/utils';

export function request(ctx) {
	const { id } = ctx.args;
	return {
		operation: 'UpdateItem',
		// this line throws error
		key: util.dynamodb.toMapValues({ id }),
		update: {
			expression: 'ADD #votefield :plusOne, version :plusOne',
			expressionNames: { '#votefield': 'upvotes' },
			expressionValues: { ':plusOne': { N: 1 } },
		},
	};
}

An error is thrown because util is implemented as an empty object, so util.dynamodb is undefined.

This library provides a utilMock export which can be used to mock @aws-appsync/utils using vitest, which needs to be installed in your application as a dev dependency.

Here is how a vitest unit test for the above code might look when using utilMock.

// in global vitest.config.ts...

/// <reference types="vitest" />
import { defineConfig } from 'vitest/config';

export default defineConfig({
	test: {
		// ... other configs
		// set clearMocks true to automatically clear utilMock between test cases
		clearMocks: true,
	},
	// ... other configs
});
import { describe, it, expect, vi } from 'vitest';
import { request } from './path/to/resolver.js';

vi.mock('@aws-appsync/utils', async () => {
	const original = await vi.importActual('@aws-appsync/utils');
	// must import utilMock dynamically due to vitest hoisting
	const { utilMock } = await import('@mikecbrant/appsyncjs-test-utils');
	return {
		...original,
		util: utilMock,
	};
});

describe('request', () => {
	// because of the way vitest hoists the vi.mock above
	// we cannot set a local reference to the mock in global scope
	let utilMock;

	// ...instead, we set it here after @aws-appsync/utils
	// module has been mocked
	beforeAll(async () => {
		const mock = await import('@aws-appsync/utils');
		utilMock = mock.util;
	});

	it('request', () => {
		const id = 'foo';
		const ctx = { args: { id } };
		const result = request(ctx);

		expect(utilMock.dynamodb.toMapValues).toHaveBeenCalledWith(id);
		// ... other assertions
	});
});

Functionality implemented in utilMock

Currently the following utilities have been implemented in utilMock:

  • base64Decode, base64Encode, urlDecorde, urlEncode Encoding utils
  • autoId, autoUlid, autoKsuid ID generation utils
  • util.error, util.appendError Error utils
    • note that these mocks simply log to console.error and do not provide any of the AppSync / GraphQL related behaviors related to resolver evaluation or response decoration
    • it is recommended that return util.error(...) be used in code if you need testable ability to exit request/response handler execution
  • authType from Type and pattern matching utils
    • note authType returns User Pool Authorization value by default
    • a different authType return value of can be set by overriding the mock implementation on a test or suite-specific basis as shown in the example below
// change implementation for all tests in suite
beforeEach(() => {
	util.authType.mockImplementation(() => 'API Key Authorization');
});
  • unauthorized from Authorization utils
    • note that this mock simply logs to console.error and does not throw and exit function or provide any of the AppSync / GraphQL related behaviors related to resolver evaluation or response decoration
    • it is recommended that return util.unauthorized() be used in code if you need testable ability to exit request/response handler execution
  • all DynamoDB helpers in util.dynamodb
  • all Runtime utilities
    • note earlyReturn simply logs to console.info and return input argument, but does not not exit function or provide any of the AppSync / GraphQL related behaviors related to resolver evaluation or response decoration
    • it is recommended that return util.runtime.earlyReturn(...) be used in code if you need testable ability to exit request/response handler execution
  • util.time.nowISO8601 from Time helpers

Functional testing for resolvers

The primary means for conducting functional testing of resolvers is via EvaluateCode API AppSync service call, typically using EvaluateCodeCommand in aws-sdk. This library provides tooling to simplify working around some of the rough edges of this service, especially for use in automated testing.

Some key considerations when testing with EvaluateCode API:

  • You will quickly run up against API rate limits as you grow your test coverage
    • throttling test executions is important to scaling your test coverage
    • this library extends the underlying aws-sdk AppSyncClient class to provide reasonable means for throttling and retrying throttled requests in the context of parallel test execution
    • you will still need to set and manage throttle config and test timeouts relevant to your test setup. You should expect to run ~300 tests per minute with default throttle settings.
  • There are three categories of errors that need to be disambiguated from the EvaluateCodeCommand error response shape.
    • message-only errors which are typically added by util.error and util.appendError calls. These are errors which are potentially surfaced in GraphQL responses and should be considered "normal" part of the response.
    • "compile"-time code errors generated during EvaluateCode. These represent some problem with your code and should be treated like an error in your code under test.
    • run-time message-only errors. These must be disambiguated from util.*Error messages by RegExp pattern. These are also errors with your code and should be treated as an error in your code under test.
    • the evaluateCode and evaluateFile methods exposed by this library throw EvaluateCodeError (a subclass of AggregateError) errors for last two cases. This let's you easily test code which interacts with util.*Error methods in normal evaluate* results, while letting your tests fail loudly with uncaught EvaluateCodeError when there is something wrong with your code. Each error contains pertinent details and contextual log entries on it's errors property.
  • When using Typescript, the EvaluateCodeCommandOutput type is difficult to work with as pretty much all properties at all levels are optional due to loosely structured shape in combination with JSON string values.
    • this library provides stronger guarantees on both it's input and response shapes, along with managing with JSON serialization concerns with underlying API, leaving test code to work only with the shapes it expects at these boundaries.
  • For most meaningful tests, you should pass in meaningful AppSync context objects to exercise various code paths based on passed args, stash, etc.
    • currently info property can not be passed in context object, as it is not supported by EvaluateCode API :~(
  • If writing your resolvers in Typescript, you need to build APPSYNC_JS-compliant code before testing it.
    • @mikecbrant/appsyncjs-cli library offers CLI build tool for use in npm run scripts to eliminate need for local esbuild configurations.

Usage

package.json

Typical run script config for an application using these tools might include the following:

	scripts: {
		"build": "appsyncjs build",
		"test": "pnpm run build && vitest",
	},

Exports

import {
	evaluateCode,
	EvaluateCodeError,
	evaluateFile,
	getThrottledClient,
} from '@mikecbrant/appsyncjs-test-utils';
  • evaluateFile is probably the most useful of the exports as typically test cases would be set up to work with built distribution files holding request and response functions for a single top-level or step resolver. This function takes responsibility for reading file into string for usage on API.
const { error, evaluationResult, logs } = await evaluateFile({
	file: '/path/to/built/file.js',
	context: { args: {}, stash: {}, /* ...other props */ }, // Appsync context object
	function: 'request', // or 'response' depending on function to test
});
  • evaluateCode is used to pass code as string when needed. evaluateFile uses this under the hood after extracting file contents.
  • EvaluateCodeError is subclass of AggregateError with no other additional functionality, just differnent name for distinction.
  • getThrottledClient allows you to manually set up your ThrottledAppsyncClient instance with non-default configuations for maxRetries, opsPerSecond throttle setting or any other underlying AppSyncClient options, such as region.

Vitest example

There are two global test configuration settings which relate to our API request throttling. You will likely need to adjust these settings as you build out test coverage.

{
	test: {
		// ...other configs

		// global timeout adjustment (if not done in test files)
		// may need to be adjusted based on your throttle rate
		// and overall test execution timeframe
		testTimeout: 30000,

		// by limiting threads, we try to run all tests via singleton
		// instance of throttle mechanism
		// this slows down overall test execution
		poolOptions: {
			threads: {
				singleThread: true,
			},
		},
	},
}

A test file using this functionality might look like the following:

import { describe, expect, it, vi } from 'vitest';
import { glob } from 'glob';
import { readFile } from 'node:fs/promises';
import { evaluateCode, evaluateFile } from '@mikecbrant/appsyncjs-test-utils';

describe('my test suite', () => {
	it('passes basic check with no args or stash in context', async () => {
		const files = glob('/path/to/built/files/**/*.js');
		const context = { args: {}, stash: {} };

		const promises = [];

		files.forEach((file) => {
			// check both request and response
			promises.push(
				evaluateFile({
					file,
					context,
					function: 'request',
				}),
				evaluateFile({
					file,
					context,
					function: 'response',
				}),
			);
		});

		const results = await Promise.allSettled(promises);

		// we expect no rejections due to EvaluateCodeError
		results.forEach((result) => {
			expect(result.status).toEqual('fulfilled');
			// response detail available as...
			// const { error, evaluationResult, logs } = result.value;
		});
	});

	// example using lower-level evaluateCode
	it('request works with code read from file', async () => {
		const code = await readFile('/path/to/built/file.js');
		const context = { args: { foo: 'bar' } };
		const expected = { bat: 'baz' };

		const { error, evaluationResult, logs } = await evaluateCode({
			code,
			context,
			function: 'request',
		});

		expect(error).toEqual(undefined);
		expect(evaluationResult).toEqual(expected);
		expect(logs.length).toEqual(0);
	});
});

Throttle configuration

If you need to change the default maxRetries (3) and opsPerSecond (5) for the throttle mechanism, you can call getThrottledClient somewhere in test setup (before any evaluate* calls). This will initialize the ThrottledAppsyncClient singleton for the running process. This singleton would otherwise have been loaded lazily on first evaluate* call with default config.

Vitest example:

describe('my test suite', async () => {
	beforeAll(() => {
		// initialize client singleton
		getThrottledClient({
			maxRetries: 2,
			opsPerSecond: 10,
			region: 'us-east-1',
			// ...other AppSyncClient opts
		});
	});

	// other tests
});

Package Sidebar

Install

npm i @mikecbrant/appsyncjs-test-utils

Weekly Downloads

86

Version

1.2.3

License

MIT

Unpacked Size

31.2 kB

Total Files

28

Last publish

Collaborators

  • mikecbrant