Testing Ash – share your design and best practices

Hello,

After a following several get started tutorials and trying a few things, I’m seriously considering migrating my project to Ash framework.

Before I really start to migrate, I would like a clean view on how to test my app under Ash framework.

I’ve rode a few posts but not a lot talking about that:

And from the official doc:

I learned with Elixir community that tests are a very important part of an application, and I like the TDD approach even if I don’t follow it all the time. Tests remain essential in a development process, I don’t invent anything.

That’s why I create this post.

My goal is to regroup feedbacks from the community on how you handle the tests of your application, when using Ash framework, before than in-depth articles and tutorials are written on this subject.

I have my own questions to feed the subject:

Usage

1. Resource generation (fixture)

Within a classical Phoenix application, fixtures are written as follow:

defmodule MyApp.SupportFixtures do
  @moduledoc """
  This module defines test helpers for creating
  entities via the `MyApp.Support` context.
  """

  @doc """
  Generate a ticket.
  """
  def ticket_fixture(attrs \\ %{}) do
    {:ok, ticket} =
      attrs
      |> Enum.into(ticket_valid_attrs())
      |> MyApp.Support.create_ticket()

    ticket
  end

  def ticket_valid_attrs() do
    %{subject: "some subject"}
  end
end

Then you can test like that, and it works:

defmodule MyApp.SupportTest do
  use MyApp.DataCase

  alias MyApp.Support

  import MyApp.SupportFixtures

  @tag :phoenix
  describe "tickets" do
    test "list_tickets/0 returns all tickets" do
      ticket = ticket_fixture()
      assert Support.list_tickets() == [ticket]
    end
  end
end

Now with Ash Framework, generating the fixture can looks like:

defmodule MyApp.SupportFixtures do
  @moduledoc """
  This module defines test helpers for creating
  entities via the `MyApp.Support` context.
  """

  alias MyApp.Support
  alias MyApp.Support.Ticket

  @doc """
  Generate a ticket.
  """
  def ticket_fixture(attrs \\ %{}) do
    final_attrs = attrs |> Enum.into(ticket_valid_attrs())

      Ash.Changeset.for_create(Ticket, :create, final_attrs)
      |> Support.create!()
  end

  def ticket_valid_attrs() do
    %{subject: "some subject"}
  end
end

And the test could be:

defmodule MyApp.SupportTest do
  use MyApp.DataCase

  alias MyApp.Support

  import MyApp.SupportFixtures

  @tag :ash
  describe "tickets" do
    alias MyApp.Support.Ticket

    test "read returns all tickets" do
      ticket = ticket_fixture()

      assert Ash.Query.for_read(Ticket, :read) |> Support.read!() ==
               [ticket]
    end
  end
end

But this test will fail, with the following error message:
(because coloration doesn’t help here, the only difference here is on the metadata field)

Assertion with == failed
     code:  assert Ash.Query.for_read(Ticket, :read) |> Support.read!() == [ticket]
     left:  [
              %MyApp.Support.Ticket{
                __lateral_join_source__: nil,
                __meta__: #Ecto.Schema.Metadata<:loaded, "tickets">,
                __metadata__: %{
                  selected: [:id, :subject, :status, :representative_id]
                },
                __order__: nil,
                aggregates: %{},
                calculations: %{},
                id: "9ec80178-b60d-48f2-b86e-892300d6ba90",
                representative: #Ash.NotLoaded<:relationship, field: :representative>,
                representative_id: nil,
                status: :open,
                subject: "some subject"
              }
            ]
     right: [
              %MyApp.Support.Ticket{
                __lateral_join_source__: nil,
                __meta__: #Ecto.Schema.Metadata<:loaded, "tickets">,
                __metadata__: %{},
                __order__: nil,
                aggregates: %{},
                calculations: %{},
                id: "9ec80178-b60d-48f2-b86e-892300d6ba90",
                representative: #Ash.NotLoaded<:relationship, field: :representative>,
                representative_id: nil,
                status: :open,
                subject: "some subject"
              }
            ]

The basic solution I found to fix it is querying the ticket from the database after it’s creation inside the fixture:

defmodule MyApp.SupportFixtures do
  @moduledoc """
  This module defines test helpers for creating
  entities via the `MyApp.Support` context.
  """

  alias MyApp.Support
  alias MyApp.Support.Ticket

  @doc """
  Generate a ticket.
  """
  def ticket_fixture(attrs \\ %{}) do
    final_attrs = attrs |> Enum.into(ticket_valid_attrs())

    ticket =
      Ash.Changeset.for_create(Ticket, :create, final_attrs)
      |> Support.create!()

    Ash.Query.for_read(Ticket, :by_id, %{id: ticket.id})
    |> Support.read_one!()
  end

  def ticket_valid_attrs() do
    %{subject: "some subject"}
  end
end

But I find this solution is redundant. I wonder if anyone has a better solution for such a case. How would you handle the variations of the metadata field?

2. JSON schema assertion

Within a classical Phoenix App, each schema has it’s own controller to handle requests at the server level, and JSON module to render the data:
Controller

defmodule MyAppWeb.Api.Support.TicketController do
  use MyAppWeb, :controller

  alias MyApp.Support

  action_fallback MyAppWeb.Api.FallbackController

  def index(conn, _params) do
    tickets = Support.list_tickets()
    render(conn, :index, tickets: tickets)
  end
end

JSON view

defmodule MyAppWeb.Api.Support.TicketJSON do
  alias MyApp.Support.Ticket

  @doc """
  Renders a list of tickets.
  """
  def index(%{tickets: tickets}) do
    %{data: for(ticket <- tickets, do: data(ticket))}
  end

  def data(%Ticket{} = ticket) do
    %{
      id: ticket.id,
      subject: ticket.subject,
      status: ticket.status,
    }
  end
end

Then you can test it like that:

defmodule MyAppWeb.Api.Support.TicketControllerTest do
  use MyAppWeb.ConnCase

  import MyApp.SupportFixtures

  alias MyAppWeb.Api.Support.TicketJSON

  setup %{conn: conn} do
    {:ok, conn: put_req_header(conn, "accept", "application/json")}
  end

  setup [:create_and_set_api_key]

  describe "index" do
    setup [:create_ticket]

    @tag :phoenix
    test "lists all tickets", %{conn: conn, ticket: ticket} do
      conn = get(conn, ~p"/api/v1/support/tickets")

      assert json_response(conn, 200)["data"] == [
               ticket |> to_normalised_json()
             ]
    end

  defp create_ticket(_) do
    ticket = ticket_fixture()

    %{ticket: ticket}
  end

  defp to_normalised_json(data) do
    data
    |> TicketJSON.data()
    |> normalise_json()
  end
end

With normalise_json/1 defined in MyAppWeb.ConnCase module:

defmodule MyAppWeb.ConnCase do
  @moduledoc """ ... """
  use ExUnit.CaseTemplate

  import Plug.Conn

[...]
  @doc """
  Format data to be normalised JSON and pass asserts in tests.
  """
  def normalise_json(data) do
    data
    |> Jason.encode!()
    |> Jason.decode!()
  end
[...]
end

It makes the assertion easy with any kind of schema since we pass the created ticket to the same function used by the controller to render the data.

With AshJsonApi, rendered resources are autogenerated from the resource description, so how would you make the assertion easy with data coming from a newly created ticket for instance? It would be nice to be able to use the same function that is used by AshJsonApi.Controllers.Get to generate the JsonApi schema for instance, but I’m currently not used to how it works.

defmodule MyAppWeb.AshControllerTest do
  use MyAppWeb.ConnCase

  import MyApp.SupportFixtures

  defp create_ticket(_) do
    ticket = ticket_fixture()

    %{ticket: ticket}
  end

  setup %{conn: conn} do
    {:ok, conn: put_req_header(conn, "accept", "application/vnd.api+json")}
  end

  setup [:create_and_set_api_key]

  describe "index" do
    setup [:create_ticket]

    @tag :ash
    test "lists all tickets", %{conn: conn, ticket: ticket} do
      conn = get(conn, ~p"/ash/tickets")

      assert json_response(conn, 200)["data"] == "data"
    end
  end
end

This code renders an error message:

Assertion with == failed
     code:  assert json_response(conn, 200)["data"] == "data"
     left:  [%{"attributes" => %{"representative_id" => nil, "status" => "open", "subject" => "some subject"}, "id" => "4ed2d671-2961-4ffc-9435-40413862ecf4", "links" => %{}, "meta" => %{}, "relationships" => %{"representative" => %{"links" => %{}, "meta" => %{}}}, "type" => "ticket"}]
     right: "data"

I could copy/paste the left side and re-arrange it to populate values with variables such as "id" => ticket.id, but I’m looking for something that I don’t need to modify in tests each time I will bring modifications to the JSON rendered schema.

Design

1. Design Ash controllers tests

As we have seen in my previous examples, a common design for API controllers in Phoenix applications is to have:

  • 1 Controller file per schema
  • 1 JSON file per schema

With Ash, and more specifically AshJsonApi, we have:

  • 1 generic (dependency level) Controller file per action
  • 1 generic (dependency level) JSON generator → for all resources? (to clarify, my comprehension is not perfect on it for now)

So I wonder what would be the best strategy for tests:

  1. create a ControllerTest file for each resource (equivalent to ControllerTest file for each schema which I currently have on Phoenix)
    OR
  2. create a ControllerTest file for each action

While writing it, I think the first option is the best, as I find it logic to regroup tests by resources (it creates a better overview of what you can / cannot do with your resource’s API, just as you have an overview of each resource in their respective Ash.Resource file).

Maybe some of you came with a third approach that I haven’t thought of, feedbacks are more than welcome to share the best practices!



I put again my questions here:

Usage

  1. How would you handle the variations of the metadata field?
  2. How would you normalize the assigns with JSON renders in controllers tests?

Design

  1. How do you organize your controller tests with Ash (AshJsonApi or AshGraphQL even if I’m not sure to use the second in the short term)
5 Likes

This is a very well written post. Thank you :bowing_man:. As part of the documentation overhaul, we will be adding some testing guidelines. I believe that testing is very situational, but there are a few things that make testing Ash code a bit unique.

Here are some things, some in response to your questions, some things worth knowing while testing, in no particular order :slight_smile:

Actions vs Seeds

While I often suggest using your resource actions to create data when you can, you may also need to reach for seeds. This can often make large differences in the speed of your tests. Ash has tools for this in Ash.Seed. For example:

Ash.Seed.seed!(%Ticket{})` 

Additionally, @jimsynz has created a layer on top of Ash.Seed with great ergonomics that many folks enjoy called Smokestack.

Generators and StreamData

Ash has utilities built in that work with StreamData to allow for generating valid values (and in the future invalid values). This can be used with Ash.Generator, for example:

Ash.Generator.many_changesets(%Ticket{}, :update, 10)

And it can also be used for property testing:

check all(input <- Ash.Generator.action_input(Resource, :create) do
  ...
end

Handling metadata

You can solve this one of two ways:

  1. use pattern matching or more specific assertions, not ==. This doesn’t work everywhere, but can be useful.

For example:

assert [%{id: ^ticket_id}] = 
assert [result] = Ash.Query.for_read(Ticket, :read) |> Support.read!()
assert result.id == ticket.id
  1. Use Ash.Test.strip_metadata/

There is a tool for this, called Ash.Test.strip_metadata/1. This can let you make your assertions, i.e

assert strip_metadata(Ash.Query.for_read(Ticket, :read) |> Support.read!()) == strip_metadata([ticket])

You can call it on result tuples or lists, and it will strip the metadata of any contained records.

Testing APIs

Honestly, it isn’t generally possible for Ash to produce something that doesn’t match the schema it defines. I personally would be testing specific behavioral things. (and I’d use AshJsonApi.Test to do it). For example:

  import AshJsonApi.Test
  
  @tag :phoenix
  test "lists all tickets", %{conn: conn, ticket: ticket} do
    MyApp.Support
   # get and assert on status
    |> get("/api/v1/support/tickets", status: 200) 
    |> assert_data_equals([%{...}, %{...}])
  end

I think there are some potentially missing assertions for AshJsonApi that would be really nice, like assert_data_matches where you provide a pattern, and so on. But you could copy the pattern of our assertions (and maybe PR some to ash_json_api, as they assert and return the original conn. (Actually, I found a few assertions that weren’t doing this while checking this out, and have pushed dup a fix for them :slight_smile: )

But ultimately, the point is that with Ash you don’t really have to test “can it turn an instance of my resource into a valid response”, you need to test the behavior of the action. i.e that all existing tickets are contained in the response, or whatever other action-specific things you have going.

Unit Testing in Ash is different

For example, you can unit test a calculation directly, i.e

calculate :full_name, :string, expr(first_name <> " " <> last_name)
assert Ash.calculate!(User, :full_name, refs: [first_name: "Zach", last_name: "Daniel"]) == "Zach Daniel"

When testing this calculation, I would likely do it at the resource level, not the api level. This same thing is true for a lot of cases for me. I would likely have most of my tests at the action/Resource level.

Conclusion

So, that was a lot, and I’m not sure I’ve truly answered all your questions, but its all the time I have left right now, so I’ll leave it at that for the time being :laughing:. Let me know your thoughts and I will respond tomorrow :bowing_man:

6 Likes

Hey @zachdaniel , thanks a lot for your kind answer with a lot of information and examples!

Indeed you gave me very practical inputs that drives me on the good way I think!

You answered to:

  1. How to handle metadata fields variations through pattern matching or by using Ash.Test.strip_metadata/1
  2. How to normalize JSON data render from a resource to test the ‘controller’ from AshJsonApi it would be easier to test the behavior of an action rather than it’s precise result
  3. How to design Ash controller tests it’s better to test actions at the resource level rather than API level, so I guess it could looks like:
    1 test file per resource to test actions
    +
    1 test file per resource to test controllers (AshJsonApi for instance)

The only thing you haven’t answered, but it was not a really clear question, is how could I modify the JSON render for my resources, to make it more custom if needed. I know it’s made from the code declared on the Ash.Resource module, so to modify it it’s obvious that I can modify the code I declared in that module. But is there another way? I’ve read on many sides that Ash is well customizable (a framework with open boundaries), but I’m a noob haha and it’s not obvious. Anyway… This question is a bit out of the scope of my first post, so don’t take time to answer it if you’re very busy, I also have to pursue the effort to dig on things by myself!

And thanks a lot again for the time you spent answering my question with details and examples, that’s really kind and I appreciate a lot!

I’ve also learned about Ash.Seeds and Ash.Generators thanks to you, and I will probably use them in future cases!

I hope my first post and your answers will already serve other developers interests!

1 Like

In general, we don’t typically “customize the json”, as much as we customize the resource itself. We can potentially add things like rename_fields [foo: "SomethingElse], which we do on an as-needed basis, but if you wanted to do something like add a field in the response for a calculated value, you’d add a calculation

calculate :full_name, :string, expr(first_name <> " " <> last_name)

and then that calculation can be requested by users with ?fields=foo,bar,full_name.

You can configure what fields are included by default with

json_api do
  default_fields [:foo, :bar, :full_name]
end

and then they won’t need to be requested.

ash_json_api is built to be a JSON:API compliant API, which is why the structure is relatively rigid.

2 Likes

Thank @zachdaniel you for these new precision!

I have a better understanding now of how to construct it the right way with the help of Ash.

I’ll migrate my project soon and maybe will create a post on that experience.

1 Like