Today, we hope to make testing Absinthe a bit easier for you. We believe that it's a great library for writing GraphQL applications, but if you previously haven't done much work on an Absinthe application, you might find some things a bit tricky to test.
The worst part of this is that some of these really tricky things to test are some of the best parts of Absinthe, and so, with them being a bit hard to test, folks might end up not using those parts of the library as much as they should.
Today's Ingredients
The main ingredient today is a GraphQL schema representing a blog. There are also some references to things that we're not going to show, but to avoid unnecessary complexity, we will assume that
they're there and working as expected. For example, we're not going to be looking
at the "application" logic in modules like MyApp.Comments
, as we don't need to do that in order to understand the testing that we'll carry out.
Here's our main ingredient: the GraphQL schema below that represents a blog.
defmodule MyAppWeb.Schema do use Absinthe.Schema import Absinthe.Resolution.Helpers alias MyApp.{Comments, Posts, Repo, User, Users} @impl true def context(ctx) do loader = Dataloader.new() |> Dataloader.add_source(Comments, Dataloader.Ecto.new(Repo)) |> Dataloader.add_source(Posts, Dataloader.Ecto.new(Repo)) |> Dataloader.add_source(Users, Dataloader.Ecto.new(Repo)) Map.put(ctx, :loader, loader) end # This is only public so we can show how to test it later 😀 def resolve_unread_posts(user, _, %{loader: loader}) do loader |> Dataloader.load(Users, :posts, user) |> Absinthe.Resolution.Helpers.on_load(fn loader -> unread_posts = loader |> Dataloader.get(Users, :posts, user) |> Enum.filter(& !&1.is_read) {:ok, unread_posts} end) end object :user do field(:name, non_null(:string)) field(:age, non_null(:integer)) field(:posts, non_null(list_of(non_null(:post))), resolve: dataloader(Posts)) field(:unread_posts, non_null(list_of(non_null(:post)), resolve: &resolve_unread_posts/3) end object :post do field(:title, non_null(:string)) field(:body, non_null(:string)) field(:is_read, non_null(:boolean)) field(:comments, non_null(list_of(non_null(:comment))), resolve: dataloader(Comments)) end object :comment do field(:body, non_null(:string)) field(:user, non_null(:user), resolve: dataloader(Users)) end query do field(:users, non_null(list_of(non_null(:user))), resolve: fn _, _, _ -> Repo.all(User) end) end end
What to Test and Where
When you've got a GraphQL API and you're using Absinthe, it means you generally have three "layers" in your application that you can test. They are (from the outermost layer to the innermost layer):
- Document resolution — where you actually send a GraphQL document as a user would and resolve that document
- Resolver functions — which are just functions and so can be tested in the normal way that you'd test any other function
- Your application functions — which is basically everything else 😀
Like in any other application, you'll be writing tests at each of these levels. The number of tests you write at each level, and what you test, is often a matter of personal preference.
Reason to Test at These Levels in Absinthe
The important thing is: because of how certain kinds of behavior are separated in Absinthe, and in the way it resolves documents, there is some behavior that can only be tested at some levels. For example, if you're using the default resolution function for a field in an object, you can only test the resolution of that field at the document resolution level.
Similarly, if you're using any of the Absinthe.Resolution.Helpers.dataloader
functions, you won't be able to test that behavior anywhere but at the document resolution level.
This is a bit of a pattern actually — using Dataloader
is basically a necessity for most
GraphQL applications, but using it also makes that behavior a bit harder to test and also forces
us to test certain behavior at a higher level, in a more expensive test than one might want.
Testing Document Resolution
So let's focus on testing at document resolution. Since we know we're going to have to write some tests where we're resolving an actual document, we should ensure that those tests are as valuable to us as they can be! Since these will already be rather expensive tests given that they cover the entire stack, you might as well try and squeeze all the value out of them that you can. These tests will end up being rather large, and hopefully, you won't have to have too many of them.
One thing I see somewhat frequently is folks trying to make these tests smaller and more manageable, but this comes with some potential issues. One of the great things about GraphQL is that clients can send a document that only requests the data they need, making it easy to compose bits of functionality together into a larger API.
However, this means that it's also really easy to accidentally miss functionality when testing an object's resolution! This can lead to errors when resolving a field in a type that isn't seen until that field is actually requested by a client in production, and that's not good.
So, the thing that I've relied on is the rule that when I'm testing document resolution, I always request every field in whichever object I'm testing.
How to Request Every Field in a Test
But how do we make this easy to do? Luckily, there's a function for that! In the assertions
library, there are some helpers for testing Absinthe applications.
Included in those helpers, is the document_for/4
function which automatically creates a document with all fields in the given object and will also
recursively include all fields in any associated objects to a given level of depth! The default there is 3, but since we want 4 levels deep we'll need to override that. So, instead of a test
that looks like this:
test "resolves correctly", %{user: user} do query = """ query { users { name age posts { title body isRead comments { body user { name age } } } unreadPosts { title body isRead comments { body user { name age } } } } } """ assert {:ok, %{data: data}} = Absinthe.run(query, MyAppWeb.Schema, context: %{current_user: user}) assert %{ "users" => [ %{ "name" => "username", "age" => 35, "posts" => [ %{ "title" => "post title", "body" => "post body", "isRead" => false, "comments" => [ %{ "body" => "comment body", "user" => %{ "name" => "username", "age" => 35 } } ] } ], "unreadPosts" => [ %{ "title" => "post title", "body" => "post body", "isRead" => false, "comments" => [ %{ "body" => "comment body", "user" => %{ "name" => "username", "age" => 35 } } ] } ] } ] } = data end
As an exampe, we can have a test that looks like this instead:
test "resolves correctly", %{user: user} do query = """ query { users { #{document_for(:user, 4)} } } """ assert_response_matches(query, context: %{current_user: user}) do %{ "users" => [ %{ "name" => "username", "age" => 35, "posts" => [ %{ "title" => "post title", "body" => "post body", "isRead" => false, "comments" => [ %{ "body" => "comment body", "user" => %{ "name" => "username", "age" => 35 } } ] } ], "unreadPosts" => [ %{ "title" => "post title", "body" => "post body", "isRead" => false, "comments" => [ %{ "body" => "comment body" "user" => %{ "name" => "username", "age" => 35 } } ] } ] } ] } end end
This also gives us the added benefit of automatically querying any new fields as they're added to types instead of needing to manually add those new fields in all the tests in which that type is used, further increasing the value of our existing tests!
We can also see in the example above that we're using the assert_response_matches/4
macro which gives us a really nice way to match against the response from our query. This is a
pretty small wrapper around the "default" way of testing document resolution shown in the original
example, but it gives us a great symmetry between the document and the response and also serves
as really great documentation! This way, you see the intended shape of the response clearly
in the test, which could make this a valuable test even for non-Elixir developers to use as
guidance on how to use this API.
But, in general, by taking this comprehensive approach to testing at this level with the guideline of trying to have at least one of these tests covering every object in your GraphQL schema, you should have some confidence that at the very least, every type in your schema can resolve without error, and it helps us know that if we're using Dataloader, we're able to successfully resolve those associations.
Testing Resolver Functions Using Dataloader
The final part of testing that we'd like to talk about since they are tricky to test is testing resolver functions that use Dataloader's on_load/2
function. They are a bit tricky because these functions return a middleware tuple instead of something a bit easier to test. This means
that many people test the behavior in these functions at the document resolution level, but that's
not strictly necessary! If you take a look at the tuple that's returned, you'll see the trick to
testing those functions.
That function returns a tuple that looks like {:middleware, Absinthe.Middleware.Dataloader, {loader, function}}
,
and so many folks might expect it to be hard to test, but it's not! If we want to test
the actual behavior in that function, which in this case is basically just that Enum.filter/2
call, then we can write our test like this:
test "only returns unread posts" do context = MyApp.Schema.context(%{}) {_, _, {loader, callback}} = resolve_unread_posts(user, nil, context) assert {:ok, [%{is_read: false}]} = loader |> Dataloader.run() |> callback.() end
That's not too bad, right? It just required us to look at the return value from the middleware. All we needed for the test was right there! That callback function that is returned in that middleware tuple is the function that's actually called by Absinthe when resolving the field. Given that the majority of your database access in an Absinthe application should be going through Dataloader, knowing how to use and test functions like this is going to be very helpful as your application develops and more complicated functionality is needed.
Conclusion
We've now seen the three levels of testing that we have at our disposal, how to test at the document resolution level without missing critical pieces of the application, and how to test those tricky functions that use Dataloader. With these three things in mind, testing your Absinthe application should, hopefully, be much easier and more robust.
P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, subscribe to our Elixir Alchemy newsletter and never miss a single post!