Phoenix Testing Issue with ExMachina: Can't get nested factory to work so relationships fail

I am having inconsistent behavior with ExMachina. Within a Factory, I can get one nested insert to work but not another. But I know that both factories work if used outside this factory. I keep getting this esoteric error message:

** (CaseClauseError) no case clause matching: 3

As an aside … this is a very confusing error message for beginners. The number at the end changes with each run. Using IO.puts, I figured out that it has to do with a relationship between my Author and my Resource. It is a many-many relationship. The Author inserted within my Resource Factory fails to produce an ID so that relationship is incomplete which causes the test to fail.

The Author Factory works: If I create an Author using insert(:author) in the test, the author is created perfectly with an ID. So I know that the Author Factory is capable of producing an author that is inserted into the database.

But when I create an Author inside the Resource Factory, it fails to generate an ID. When I create a User inside the Resource Factory, it produces a User with an ID. This is what is so frustrating. The User Factory works within the Resource Factory but the Author Factory does not!

Below are the Author Factory and Resource Factory. Below that is the IO.puts output from within the Resource Factory. Those printouts show me that build(:user) produces a User with an ID that is used by Resource. But build(:author) does not produce an Author with an ID so that relationship within Resource is never completed.

This makes no sense. I have verified that the Author Factory and User Factory work. So why does one work inside the Resource Factory but the other one does not???

STANDARD FACTORY SETUP

defmodule MyApp.Factory do
  use ExMachina.Ecto, repo: MyApp.Repo
  use MyApp.UserFactory
  use MyApp.AuthorFactory
  use MyApp.ResourceFactory
end

AUTHOR FACTORY

defmodule MyApp.AuthorFactory do
  alias MyApp.Authors.Author

  defmacro __using__(_opts) do
    quote do

      # BEGIN Author Factory
      def author_factory(attrs \\ %{}) do

        user = build(:user)

        author =
          %Author{
            first_name: sequence(:first_name, &"Author#{&1}fn"),
            last_name: sequence(:last_name, &"Author#{&1}ln"),
            company_name: sequence(:company_name, &"Author#{&1}Company"),
            website_url: sequence(:website_url, &"https://author#{&1}.com"),
            city: "",
            state_region: "",
            country: "",
            user: user,
            user_id: user.id
          }
        merge_attributes(author, attrs)
      end
      # END Author Factory

    end
  end
end

RESOURCE FACTORY

defmodule MyApp.ResourceFactory do
  alias MyApp.Resources.Resource

  defmacro __using__(_opts) do
    quote do

      # BEGIN Resource Factory
      def resource_factory(attrs \\ %{}) do

        IO.puts "--------- Resource Factory -------------"

        user = build(:user)
        IO.puts "User:"
        user |> IO.inspect(charlists: :as_lists, limit: :infinity)

        author = build(:author)
        IO.puts "Author:"
        author |> IO.inspect(charlists: :as_lists, limit: :infinity)

            resource =
              %Resource{
                title: sequence("Resource"),
                description: sequence("This is Resource"),
                image_url: sequence(:image_url, &"https://www.resourceimage#{&1}.com"),
                resource_url: sequence(:resource_url, &"https://www.resource#{&1}.com"),
                publish_date: nil,
                is_free: false,
                is_private: false,
                usage_count: 0,
                user_id: user.id,
                authors: [author.id]
                }

          return = merge_attributes(resource, attrs)
          IO.puts "Resource:"
          return |> IO.inspect(charlists: :as_lists)
          return

      end
      # END Resource Factory

    end
  end
end

OUTPUT FROM IO.PUTS

--------- Resource Factory -------------
User:
#MyApp.Users.User<
  __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
  avatar: nil,
  bio: nil,
  city: "Santa Cruz",
  confirmed_at: nil,
  country: "United States",
  created_groups: #Ecto.Association.NotLoaded<association :created_groups is not loaded>,
  email: "user-576460752303422911@example.com",
  first_name: "User_fn1",
  geo_id: 29,
  geolocation: #Ecto.Association.NotLoaded<association :geolocation is not loaded>,
  groups: #Ecto.Association.NotLoaded<association :groups is not loaded>,
  hashed_password: “Not sure if I should send this around so just hiding this but it’s the usual hashed stuff”,
  **id: 222,**
  inserted_at: ~N[2023-07-04 15:25:18],
  last_name: "User_ln1",
  latlong: %Geo.Point{
    coordinates: {-122.0308, 36.9741},
    properties: %{},
    srid: 4326
  },
  lessons: #Ecto.Association.NotLoaded<association :lessons is not loaded>,
  state: "California",
  timezone: "America/Los_Angeles",
  updated_at: ~N[2023-07-04 15:25:18],
  username: "user_username1",
  zip: nil,
  ...
>
Author:
%MyApp.Authors.Author{
  __meta__: #Ecto.Schema.Metadata<:built, "authors">,
  city: "",
  company_name: "Author1Company",
  country: "",
  first_name: "Author1fn",
  **id: nil,**
  inserted_at: nil,
  last_name: "Author1ln",
  resources: #Ecto.Association.NotLoaded<association :resources is not loaded>,
  user: #MyApp.Users.User<
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    avatar: nil,
    bio: nil,
    city: "Santa Cruz",
    confirmed_at: nil,
    country: "United States",
    created_groups: #Ecto.Association.NotLoaded<association :created_groups is not loaded>,
    email: "user-576460752303422879@example.com",
    first_name: "User_fn2",
    geo_id: 29,
    geolocation: #Ecto.Association.NotLoaded<association :geolocation is not loaded>,
    groups: #Ecto.Association.NotLoaded<association :groups is not loaded>,
    hashed_password: “blah blah”,
    id: 223,
    inserted_at: ~N[2023-07-04 15:25:18],
    last_name: "User_ln2",
    latlong: %Geo.Point{
      coordinates: {-122.0308, 36.9741},
      properties: %{},
      srid: 4326
    },
    lessons: #Ecto.Association.NotLoaded<association :lessons is not loaded>,
    state: "California",
    timezone: "America/Los_Angeles",
    updated_at: ~N[2023-07-04 15:25:18],
    username: "user_username2",
    zip: nil,
    ...
  >,
  user_id: 223,
  users: #Ecto.Association.NotLoaded<association :users is not loaded>,
  state_region: "",
  updated_at: nil,
  website_url: "https://author1.com"
}
Resource:
%MyApp.Resources.Resource{
  __meta__: #Ecto.Schema.Metadata<:built, "resources">,
  **authors: [nil],**
  description: "This is Resource0",
  id: nil,
  image_url: "https://www.resourceimage0.com",
  inserted_at: nil,
  is_free: false,
  is_private: false,
  mediums: [1],
  publish_date: nil,
  resource_types: [3],
  resource_url: "https://www.resource0.com",
  user: #Ecto.Association.NotLoaded<association :user is not loaded>,
  **user_id: 222,**
  users: #Ecto.Association.NotLoaded<association :users is not loaded>,
  supplies: [1],
  title: "Resource0",
  topics: [23],
  updated_at: nil,
  usage_count: 0
}

The test is incredibly basic and is below:

defmodule MyAppWeb.ResourceLiveTest do
  use MyAppWeb.ConnCase
  import MyApp.Factory
  import Phoenix.LiveViewTest

  alias MyApp.Resources

  describe "RESOURCE FILTERING - " do

    IO.puts "-------------- TEST IO PUTS -------------------------"

    setup do
      setup_author = insert(:author)
      IO.puts "SETUP AUTHOR"
      setup_author |> IO.inspect(charlists: :as_lists)

      resource0 = insert( :resource)

      [resource0: resource0]
    end

    test "lists all resources", %{conn: conn, resource0: resource0} do
      # {:ok, _index_live, html} = live(conn, Routes.resource_index_path(conn, :index))

      IO.puts "Resource0:"
      resource0 |> IO.inspect(charlists: :as_lists)

      # assert html =~ "Listing Resources"
      # assert html =~ resource.description
    end

  end # end describe
end # end module
2 Likes

What is going on in UserFactory? The debugging prints suggest that calling build(:user) produces a record that’s been persisted to the DB, which is unusual.

Re: the specific error - authors: [author.id] is an odd construction - have you tried passing the whole Author struct instead of an ID here?

1 Like

Thank you @al2o3cr for your reply! You’ve helped me out of many predicaments. Much appreciated!

The User is basically Jose Valim’s user_fixture which I just call from the factory (see below). I wasn’t going to mess with his code. LOL. I simply pass the additional attrs I added onto his basic User.

My Resource changeset expects the id’s in a list (eg authors[2, 5, 7] because these are many to many relationships. Those IDs are based on user selections. I kind of suspect that is the issue but my changeset expects that format. The only reason it doesn’t add up is that I can see that the Author doesn’t get an ID when I call build(:author) inside Resource Factory but I can see the ID when I do insert(:author) within the Test. The key issue seems to be that the Author factory doesn’t get inserted when called within the Resource Factory.

I tried calling insert(:author) instead of build(:author) within Resource Factory and it complained about duplication. I suspect that is because when I call insert(:resource), it automatically converts any “build” within the factory into an “insert.”

I’m kind of wondering if I should just drop ExMachina and create my own Fixtures. The whole Fixtures vs Factories has definitely made the learning process more confusing because some examples use Fixtures and others use Factories. I thought using ExMachina might make things easier, but perhaps I should stick with Fixtures. Do you use Fixtures or Factories?

defmodule MyApp.UserFactory do
  alias MyApp.Users.User
  alias MyApp.UsersFixtures

  alias MyApp.Users
  alias MyAppWeb.UserAuth
  import MyApp.UsersFixtures

  
  defmacro __using__(_opts) do
    quote do

      # BEGIN User Factory
      def user_factory(attrs) do

        default = %{
          first_name: sequence("User_fn"),
          last_name: sequence("User_ln"),
          username: sequence("user_username"),
          bio: "",
          avatar: "",
          city: "Santa Cruz",
          state: "California",
          country: "United States"
        }

        user_fixture(default)

  end

      def extract_user_token(fun) do
        {:ok, captured} = fun.(&"[TOKEN]#{&1}[TOKEN]")
        [_, token, _] = String.split(captured.body, "[TOKEN]")
        token
      end
      # END User Factory

    end
  end
end

Short short version: insert == build + Repo.insert. That’s the expected behavior.

Functions like Resource.changeset are not used by ExMachina, it uses its own casting machinery:

You didn’t include any stacktrace in your original post about a CaseClauseError, but I’d guess the one it printed pointed to this code, which is expecting either maps or nils in an association:

Some of both? It’s complicated.

I personally never found the “make an object graph but don’t persist it” feature useful even in ExMachina’s Ruby predecessor (FactoryBot), and that feature is even trickier in Elixir where records can’t be aliased (referred to in multiple places but saved in only one).

For instance, trying to make an unsaved Resource with the same unsaved user listed as both user and one of the authors will lead to sadness - the first User would insert correctly, but the second would fail uniqueness validation. :frowning:

That wouldn’t be a problem in the Ruby version since resource.authors[0] and resource.user could literally point to the same object that gets mutated on save - one of the few cases where mutable state’s sneaky-action-at-a-distance is actually helpful.

1 Like

OK … this is making a lot more sense. And it explains the “case” error because I wasn’t using a case statement anywhere in my tests.

You’ve convinced me to just use Fixtures that always insert (which is what I want). Anything with a relationship needs to reference an ID in another table and all of my schemas have relationships.

Thank you for that guidance. I didn’t realize that ExMachina was not using my changesets. I’m off to write some fixtures.

One more question …

ExUnit will clean up (as in remove) any Fixtures that I created during a test automatically. Is that right? But it won’t clean up data that I seeded in the test database (eg the gigantic geolocation table with a bizillion records).

Ecto’s testing Sandbox mode + transactions will clean up along with ExUnit, when a test finishes, assuming you’ve not modified your test_helper file and it was added when you generated your app (it should be the default for Phoenix + Ecto?)

It’s not as “nice” with MySQL though. But yes, unless you changed things, it should start a transaction for a test that interacts with the database, and roll it back after the test exits.

I never touched my test_helper file but I just noticed that it says:

Ecto.Adapters.SQL.Sandbox.mode(SketchLinks.Repo, :manual)

Does the “manual” setting mean that it is expecting manual removal of data? Should that be set to something like :automatic so that it removes everything that was in the test transaction?

No, it’s referring to connection ownership.

Just leave it there and you should be good.

https://hexdocs.pm/ecto_sql/Ecto.Adapters.SQL.Sandbox.html#mode/2

You only need to do anything if you have a non typical SQL database

1 Like