"What could be easier than testing a JavaScript file?"
That's what I asked myself when I began work on an AppSync JavaScript resolver last month. I needed to transform the response from an HTTP call and perform some error checks. This should take a handful of unit tests and I'll be done in a half-hour. Wrong.
What made this particular test so difficult? What is different about AppSync's JS resolvers than plain JS? How did the AppSync Serverless plugin complicate things? All this and more, I will explain in this article.
AppSync JS Resolvers Are Amazing
In GraphQL, resolvers do the important job of connecting to a data source and transforming data in and out. They can even handle error conditions and do some basic flow control. Since 2017, AppSync has used Apache VTL as its resolver runtime language. It was fast. It was complicated. It was unknown (I learned VTL just to use AppSync resolvers; all my coworkers learned VTL just to use AppSync resolvers).
Then, in late 2022, AppSync came out with a special JavaScript runtime they called APPSYNC_JS that could power AppSync resolvers written in basic JavaScript. This runtime had to be fast (no perceivable cold starts) and cheap to run (JavaScript resolvers incur no direct user costs). To meet these goals, AWS left out certain JavaScript features like throw, try/catch, while/do-while, and async/await.
To manage some fundamentals like "throwing an error," APPSYNC_JS provides special runtime modules util
, extensions
, and runtime
. You can refer to these modules in your code by importing the @aws-appsync/utils
npm module. This lets you "throw a custom error" (util.error()
), get a timestamp (util.time.nowISO8601()
), return early (runtime.earlyReturn()
), and much more.
If you've worked with AppSync VTL resolvers (I wrote about them here, here, and here), nothing I will say today sounds terrible. Compared to where you've been, this is paradise. Context matters. That said, let's get on to some of the hurdles we need to overcome.
A Runtime for Me, Not for Thee
Remember the @aws-appsync/utils library
I introduced, above? When you have a minute, cmd-click into it and look around. There's a TypeScript declaration file, a map file, and a small JavaScript file that just handles some export bindings. What don't you see? That's right, there is no implementation. The @aws-appsync libraries are all type declarations. 100% of their functionality comes from the APPSYNC_JS runtime that AWS provides.
One of the reasons AWS provides its own runtime for its JS resolvers is that it needs to be fast. APPSYNC_JS only supports a subset of actions (remember, no throw
, no async
, no while
loops, etc.) so that it can execute your JavaScript in a consistently fast manner. In order to keep your code size down, it provides built-in utilities and modules in the runtime to do things like raise errors, create IDs, and parse timestamps. This means that if your JS resolver needs to do things like raise errors, create IDs, or parse timestamps, you cannot unit1 test your resolver.
For example, to raise (throw) an error in a JS resolver, you use the util global and invoke util.error(myError)
. If you could use throw, you could run and test the resolver in a unit test. But, given that you have no access to the runtime implementation of util.error()
, your unit test framework will correctly complain that "util.error is not a function."
AppSync Client Library to The Rescue
AWS anticipated this problem and offered a solution in its @aws-sdk/client-appsync
library. You can take your resolver code, including all your util
and runtime
references, and execute that code against AppSync's JS runtime. This execution happens in AppSync, so you will need both an Internet connection and IAM permissions to make the call. This moves us away from my definition of a "unit test," but at least we can still test2.
In our example, I can create a context
object that I expect my JS resolver's response method to receive, and then create an EvaluateCodeCommandInput
and have my AppSync client send it:
This gets us out of our initial, missing-runtime jam. But, there is another complication coming from an unlikely source, our serverless plugin.
The AppSync Serverless Plugin Gets Involved
I love the Serverless Framework and its plugin ecosystem. I can build faster with Serverless Framework and have been advocating its use for years. I love the serverless-appsync-plugin for the same reasons. In the later editions of the plugin, it added support for JS resolvers, including a built-in bundler using esbuild. This lets you write JS (or TS) using import/export
syntax (ES6) and it will boil everything down to plain, APPSYNC_JS-compatible JavaScript.
However, if you want to use ES6 syntax and test your JS resolver like I explained above, you have a problem. If you pass your TypeScript or ES6 JS file directly to AppSync, AppSync will reject it. The serverless-appsync-plugin gets around this by using esbuild to bundle your code before handing it to AppSync. We will have to do the same thing.
Fortunately, esbuild can be imported as a JavaScript library and used in your test setup. Essentially, before handing our file under test to AppSync, we're going to pass it through an esbuild.buildSync()
step like so:
This has the added benefit of bundling any import statements to other files you may have used in your file under test. All your code, including imports, ends up in a single file. AppSync can now read the resolver, execute it, and hand you results. Success!
Summary
Again, if you have experience with AppSync VTL resolvers, you are probably saying to yourself "This is a small price to pay to not have to mess with VTL" and I agree with you. In the future, I could see AWS providing its APPSYNC_JS runtime as a fully implemented library. That would let us skip the networking call and make these tests "unit" again3. I hope this helps give you the confidence to go and build with AppSync. It's an amazing service
Further Reading
AWS Documentation: JavaScript resolvers overview
Benoit Boure: Everything You Should Know About the AppSync JavaScript Pipeline Resolvers
Benoit Boure: Write Reusable Code for AppSync JavaScript Resolvers
Eric Bach: Developing and Testing AWS AppSync JavaScript Resolvers
GitHub Gist for Code Samples