Mocking AWS at the Network Level #116
thecodedrift
announced in
Thunked
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
-
We use AWS pretty heavily at Taskless, with their compute-on-demand making it easy for us to scale our bi-directional API Gateway from zero to thousands of concurrent requests with no additional effort. It's truly wonderful; that is, until it's time to write tests.
The path most developers go is to grab the aws-sdk-client-mock library, which works really well assuming you don't have presigning, are using AWS Timestream, and can keep all your AWS libraries on the exact same version to avoid type conflicts.
I'm also assuming you're here because you either need pre-signing, are using AWS Timestream, think force-upgrading all AWS client libraries for a single fix is a difficult pill to swallow, or are otherwise jammed on a traditional mocking approach. The good news is, there is another way. We just have to move from the node / module layer down to the network layer.
What Are You Testing, Really?
First, the realization. We don't actually care what the
@aws-sdk/*
libraries do.There's a lot of internals in the AWS client libraries (middleware, smithy, signing, etc). Every one of them has their own abstractions, inputs, outputs, and naming conventions. Some concepts, like middleware, are impossible to mock. None of that matters though. What we do care about is how our app reacts to network conditions, including those made by the SDK libraries.
And that we can test.
So how do you go about mocking the network? There's nock and Mock Service Worker / MSW. Both are excellent libraries; I just prefer MSW so I can use the same testing patterns on the node.js and browser. While these next sections are written for MSW, they can be easily applied to a nock-based setup.
Preparing for Network Mocks
Setting up for network based mocking requires a little bit of pre-work. Specifically, we want to knock out all our AWS environment variables with dummy values; this ensures if a request ever does fall through to the network layer, it cannot trigger a billable event. When launching any node.js app, including tests, you can include a
NODE_OPTIONS
environment variable which passes additional options through to the node process. In the case of a multi-process tester like AVA, this ensures the options are carried to each worker. For a single-process worker like Jest, any node options are passed through as if they were directly on the command line.The above snippet adjusts our test command to include additional node options.
-r <file>
tells node.js to include a file first before running any other code. In this case, we want to load a bootstrap file. Our bootstrap will take care of removing all AWS environment variables for us, first by explicitly deleting allaws_
prefixed values, then setting suitable test values for access, secrets, and tokens. I'm using dotenv for readability, but you can also explicitly setprocess.env
if you prefer.One important reason we remove all
aws_
items is because theAWS_PROFILE
environment variable messes with pre-signing. If the variable is set, smithy middleware will attempt to authenticate and load the profile in question. So, don't be clever; take a scorched earth approach to theaws_*
environment variables and ensure they're all replaced.With confidence our AWS account won't get any surprise billing, we can follow MSW's Getting Started Guide for node.js and create our handlers. We can verify MSW is working because running our tests will tell us about unhandled requests, and our first call to AWS should automatically fail.
To make it easier to mock our network requests, we'll want to create a default handler.
The Default Handler at
*
Unhandled requests from MSW are fine, but I find that it's not always helpful telling you where / why something fell through to the unhandled request. I recommend adding these two handlers at the end of your chain, ensuring you get actionable errors when tests try and call out to AWS.
Our first handler (the
169.254...
) takes care of the AWS Credential Provider. In some scenarios, like whenAWS_PROFILE
is set to a dummy value, the credential provider is automatically called by AWS. Adding a catch-all for the credential provider will tell us immediately if AWS is attempting to verify our test credentials.The second handler is a better debugger. Instead of a thrown error that tells you the network request came from within AWS, you can unpack the URL, method, headers, and body. Usually this additional data makes it much easier to see what request isn't being mocked. As a bonus, this gives you all the information you need to write a matching handler of your own.
Make a Lot of Mocks
There's no limit to the number of mocks you can have. Don't be afraid to have a dozen handlers for
https://dynamodb.*.amazonaws.com
, and returnundefined
if you don't want to handle the request. A return value ofundefined
from a handler tells MSW to try the next one in-sequence. For example, I check DynamoDB handlers for a specific table using the following TypeScript:DynamoDB operates off of JSON, making it trivial to check the
TableName
for a match. When adding these checks, don't forget toclone()
the request object! Because MSW uses the built-inRequest
object, you can only read from the request body once, just like when you're usingfetch()
.Most of your helpers will focus on "is this a
<blank>
command" and "is this a<blank>
command for resource<blank>
". You only have to write these once, and then you can reuse them anywhere.A Little JSON, A Little XML, A Few Surprises
As you mock, you're going to discover some clients operate on XML while others work with JSON, even though all v3 endpoints support JSON now. Just roll with it and follow the AWS API doc's XML responses when required. You'll know when this happens because despite returning JSON, the AWS client will complain about a missing
<
or unhandled{
in the response.Some services, like AWS Timestream, make multiple requests. The first request (the one you'd normally associate with an endpoint) just retrieves the real endpoint, and the second request goes to this discovered endpoint. When you find these discovery-based services, take advantage of wildcard routes to simplify the network mocks.
Finally, while all this may seem more difficult than the usual mocking pattern, keep with it. The AWS API itself is absurdly stable. Seriously. The SQS API version is tagged November 5, 2012. So once you get these mocks working to your liking, they'll continue to work for the foreseeable future. And if AWS changes something that causes your network requests to change, you'll find out about it immediately.
Supplement: Known Weird AWS Replies
Because not everything mocked in AWS is obvious, I'll add specific notes about libraries and mocks as I uncover them.
SQS Uses XML
It's used in almost all infrastructure, but SQS responses are XML. Even worse, you have to include an MD5 digest that is checked by a smithy middleware. The following snippet can create XML success and error responses for the AWS XML API.
S3 PutObjectCommand Requires an Empty Body
When putting an object into s3 via
PutObjectCommand
, smithy (the engine underneath the AWS client libraries) expects an HTTP body. A test response needs to include both an empty body and aContent-Length
header of0
. This helper makes it easier to create responses for the command.Beta Was this translation helpful? Give feedback.
All reactions