Guy14
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:
- Testing Ash framework apps
- Ash Api testing setup (intermittent unrelated test failures in application)
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:
- create a ControllerTest file for each resource (equivalent to ControllerTest file for each schema which I currently have on Phoenix)
OR - 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
- How would you handle the variations of the metadata field?
- How would you normalize the assigns with JSON renders in controllers tests?
Design
- 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)
Marked As Solved
zachdaniel
This is a very well written post. Thank you
. 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 ![]()
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:
- 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
- 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
)
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
. Let me know your thoughts and I will respond tomorrow ![]()
Also Liked
zachdaniel
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.
Guy14
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:
- How to handle metadata fields variations → through pattern matching or by using
Ash.Test.strip_metadata/1 - 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
- 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!
Guy14
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.
Popular in Questions
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #elixirconf-us
- #advent-of-code
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #hex









