View Source Test Resources

Mix.install([{:ash, "~> 3.0"}, {:simple_sat, "~> 0.1"}],
  consolidate_protocols: false
)

Logger.configure(level: :warning)
ExUnit.start()

Introduction

We recommend testing your resources thoroughly. Often, folks think that testing an Ash.Resource is "testing the framework", and in some very simple cases this may be true, like a simple create that just accepts a few attributes.

However, testing has two primary roles:

  1. Confirming our understanding of the way that our application behaves now
  2. Ensuring that our application does not change in unintended ways later

To this end, we highly recommend writing tests even for your simple actions. A single test that confirms that, with simple inputs, the action returns what you expect, can be very powerful.

Additionally, Ash offers unique ways of testing individual components of our resources, similar to a unit test.

While you don't necessarily need to follow all steps below, we show the various ways you may want to go about testing your resources.

Testing Resources

  • Add tests for action inputs using property testing
  • Add tests for calling action invocation using property testing
  • Add explicit tests for action inputs/invocation.
  • Add tests for our calculations
  • Test policies "in isolation" using Ash.can? (or Domain.can_*, provided by code interfaces)

Examples

defmodule User do
  use Ash.Resource,
    domain: Domain,
    data_layer: Ash.DataLayer.Ets

  actions do
    defaults [:read, create: [:admin?]]
  end

  attributes do
    uuid_primary_key :id
    attribute :admin?, :boolean do
      allow_nil? false
      default false
    end
  end
end

defmodule Tweet do
  use Ash.Resource,
    domain: Domain,
    data_layer: Ash.DataLayer.Ets,
    authorizers: [Ash.Policy.Authorizer]

  attributes do
    uuid_primary_key :id
    attribute :text, :string do
      allow_nil? false
      constraints max_length: 144
      public? true
    end

    attribute :hidden?, :boolean do
      allow_nil? false
      default false
      public? true
    end
  end

  calculations do
    calculate :tweet_length, :integer, expr(string_length(text))
  end

  relationships do
    belongs_to :user, User, allow_nil?: false
  end

  actions do
    defaults [:read, update: [:text]]

    create :create do
      primary? true
      accept [:text]
      change relate_actor(:user)
    end
  end

  policies do
    policy action_type(:read) do
      description "If a tweet is hidden, only the author can read it. Otherwise, anyone can."
      authorize_if relates_to_actor_via(:user)
      forbid_if expr(hidden? == true)
      authorize_if always()
    end

    policy action_type(:create) do
      description "Anyone can create a tweet"
      authorize_if always()
    end

    policy action_type(:update) do
      description "Only an admin or the user who tweeted can edit their tweet"
      authorize_if actor_attribute_equals(:admin?, true)
      authorize_if relates_to_actor_via(:user)
    end
  end
end

defmodule Domain do
  use Ash.Domain,
    validate_config_inclusion?: false

  resources do
    resource Tweet do
      define :create_tweet, action: :create, args: [:text]
      define :update_tweet, action: :update, args: [:text]
      define :list_tweets, action: :read
    end

    resource User do
      define :create_user, action: :create
    end
  end
end
{:module, Domain, <<70, 79, 82, 49, 0, 2, 46, ...>>,
 [
   Ash.Domain.Dsl.Resources.Resource,
   Ash.Domain.Dsl.Resources.Options,
   Ash.Domain.Dsl,
   %{opts: [], entities: [...]},
   Ash.Domain.Dsl,
   Ash.Domain.Dsl.Resources.Options,
   ...
 ]}

Write some tests

defmodule ActionInvocationTest do
  use ExUnit.Case
  import ExUnitProperties

  describe "valid inputs" do
    # now if our action inputs are invalid when we think they should be valid, we will find out here
    property "accepts all valid input" do
      user = Domain.create_user!()

      check all(input <- Ash.Generator.action_input(Tweet, :create)) do
        {text, other_inputs} = Map.pop!(input, :text)

        assert Domain.changeset_to_create_tweet(
                 text,
                 other_inputs,
                 authorize?: false,
                 actor: user
               ).valid?
      end
    end

    # same as the above, but actually call the action. This tests the underlying action implementation
    # not just initial validation
    property "succeeds on all valid input" do
      user = Domain.create_user!()

      check all(input <- Ash.Generator.action_input(Tweet, :create)) do
        {text, other_inputs} = Map.pop!(input, :text)
        Domain.create_tweet!(text, other_inputs, authorize?: false, actor: user)
      end
    end

    test "can tweet some specific text, in addition to any other valid inputs" do
      user = Domain.create_user!()

      check all(
              input <- Ash.Generator.action_input(Tweet, :create, %{text: "some specific text"})
            ) do
        {text, other_inputs} = Map.pop!(input, :text)
        Domain.create_tweet!(text, other_inputs, actor: user)
      end
    end
  end

  describe "authorization" do
    test "allows a user to update their own tweet" do
      user = Domain.create_user!()
      tweet = Domain.create_tweet!("Hello world!", actor: user)

      assert Domain.can_update_tweet?(user, tweet, "Goodbye world!")
    end

    test "does not allow a user to update someone elses tweet" do
      user = Domain.create_user!()
      user2 = Domain.create_user!()
      tweet = Domain.create_tweet!("Hello world!", actor: user)

      refute Domain.can_update_tweet?(user2, tweet, "Goodbye world!")
    end

    test "allows an admin user to update someone elses tweet" do
      user = Domain.create_user!()
      user2 = Domain.create_user!(%{admin?: true})
      tweet = Domain.create_tweet!("Hello world!", actor: user)

      assert Domain.can_update_tweet?(user2, tweet, "Goodbye world!")
    end
  end

  describe "calculations" do
    test "text length calculation computes the length of the text" do
      user = Domain.create_user!()
      tweet = Domain.create_tweet!("Hello world!", actor: user)
      assert Ash.calculate!(tweet, :tweet_length) == 12
    end
  end
end

ExUnit.run()
.......
Finished in 0.07 seconds (0.00s async, 0.07s sync)
2 properties, 5 tests, 0 failures

Randomized with seed 42546
%{total: 7, failures: 0, skipped: 0, excluded: 0}