10

Testing the Tricky Parts of an Absinthe Application

 3 years ago
source link: https://blog.appsignal.com/2020/08/19/testing-absinthe.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Elixir Alchemy

Testing the Tricky Parts of an Absinthe Application

Devon Estes on Aug 19, 2020

“I absolutely love AppSignal.”


Discover AppSignal

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

  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:

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!

Guest author Devon is a senior Elixir engineer currently working at Sketch. He is also a writer, international conference speaker, and committed supporter of open-source software as a maintainer of Benchee and the Elixir track on Exercism, as well as a frequent contributor to Elixir.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK