elixir

Testing the Tricky Parts of an Absinthe Application

Devon Estes

Devon Estes on

Testing the Tricky Parts of an Absinthe Application

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.

1defmodule MyAppWeb.Schema do
2  use Absinthe.Schema
3
4  import Absinthe.Resolution.Helpers
5
6  alias MyApp.{Comments, Posts, Repo, User, Users}
7
8  @impl true
9  def context(ctx) do
10    loader =
11      Dataloader.new()
12      |> Dataloader.add_source(Comments, Dataloader.Ecto.new(Repo))
13      |> Dataloader.add_source(Posts, Dataloader.Ecto.new(Repo))
14      |> Dataloader.add_source(Users, Dataloader.Ecto.new(Repo))
15
16    Map.put(ctx, :loader, loader)
17  end
18
19  # This is only public so we can show how to test it later 😀
20  def resolve_unread_posts(user, _, %{loader: loader}) do
21    loader
22    |> Dataloader.load(Users, :posts, user)
23    |> Absinthe.Resolution.Helpers.on_load(fn loader ->
24      unread_posts =
25        loader
26        |> Dataloader.get(Users, :posts, user)
27        |> Enum.filter(& !&1.is_read)
28
29      {:ok, unread_posts}
30    end)
31  end
32
33  object :user do
34    field(:name, non_null(:string))
35    field(:age, non_null(:integer))
36    field(:posts, non_null(list_of(non_null(:post))), resolve: dataloader(Posts))
37    field(:unread_posts, non_null(list_of(non_null(:post)), resolve: &resolve_unread_posts/3)
38  end
39
40  object :post do
41    field(:title, non_null(:string))
42    field(:body, non_null(:string))
43    field(:is_read, non_null(:boolean))
44    field(:comments, non_null(list_of(non_null(:comment))), resolve: dataloader(Comments))
45  end
46
47  object :comment do
48    field(:body, non_null(:string))
49    field(:user, non_null(:user), resolve: dataloader(Users))
50  end
51
52  query do
53    field(:users, non_null(list_of(non_null(:user))), resolve: fn _, _, _ -> Repo.all(User) end)
54  end
55end

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):

  1. Document resolution — where you actually send a GraphQL document as a user would and resolve that document
  2. Resolver functions — which are just functions and so can be tested in the normal way that you'd test any other function
  3. 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:

1test "resolves correctly", %{user: user} do
2  query = """
3  query {
4    users {
5      name
6      age
7      posts {
8        title
9        body
10        isRead
11        comments {
12          body
13          user {
14            name
15            age
16          }
17        }
18      }
19      unreadPosts {
20        title
21        body
22        isRead
23        comments {
24          body
25          user {
26            name
27            age
28          }
29        }
30      }
31    }
32  }
33  """
34
35  assert {:ok, %{data: data}} =
36           Absinthe.run(query, MyAppWeb.Schema, context: %{current_user: user})
37
38  assert %{
39            "users" => [
40              %{
41                "name" => "username",
42                "age" => 35,
43                "posts" => [
44                  %{
45                    "title" => "post title",
46                    "body" => "post body",
47                    "isRead" => false,
48                    "comments" => [
49                      %{
50                        "body" => "comment body",
51                        "user" => %{
52                          "name" => "username",
53                          "age" => 35
54                        }
55                      }
56                    ]
57                  }
58                ],
59                "unreadPosts" => [
60                  %{
61                    "title" => "post title",
62                    "body" => "post body",
63                    "isRead" => false,
64                    "comments" => [
65                      %{
66                        "body" => "comment body",
67                        "user" => %{
68                          "name" => "username",
69                          "age" => 35
70                        }
71                      }
72                    ]
73                  }
74                ]
75              }
76            ]
77          } = data
78end

As an exampe, we can have a test that looks like this instead:

1test "resolves correctly", %{user: user} do
2  query = """
3  query {
4    users {
5      #{document_for(:user, 4)}
6    }
7  }
8  """
9
10  assert_response_matches(query, context: %{current_user: user}) do
11    %{
12      "users" => [
13        %{
14          "name" => "username",
15          "age" => 35,
16          "posts" => [
17            %{
18              "title" => "post title",
19              "body" => "post body",
20              "isRead" => false,
21              "comments" => [
22                %{
23                  "body" => "comment body",
24                  "user" => %{
25                    "name" => "username",
26                    "age" => 35
27                  }
28                }
29              ]
30            }
31          ],
32          "unreadPosts" => [
33            %{
34              "title" => "post title",
35              "body" => "post body",
36              "isRead" => false,
37              "comments" => [
38                %{
39                  "body" => "comment body"
40                  "user" => %{
41                    "name" => "username",
42                    "age" => 35
43                  }
44                }
45              ]
46            }
47          ]
48        }
49      ]
50    }
51  end
52end

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:

1test "only returns unread posts" do
2  context = MyApp.Schema.context(%{})
3  {_, _, {loader, callback}} = resolve_unread_posts(user, nil, context)
4
5  assert {:ok, [%{is_read: false}]} =
6    loader
7    |> Dataloader.run()
8    |> callback.()
9end

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!

Share this article

RSS
Devon Estes

Devon Estes

Guest author Devon is a senior Elixir engineer currently working at Sketch. He is also a writer and international conference speaker. As a committed supporter of open-source software, he maintains Benchee and the Elixir track on Exercism, and frequently contributes to Elixir.

All articles by Devon Estes

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps