ExMachina

repository·main·Indexed 24 days ago

https://github.com/beam-community/ex_machina

A test data generation library for Elixir that simplifies the creation of complex test data and associations. It works as a standalone tool for any data structure or integrates with Ecto for database persistence. Key features include sequences for unique values, derived attributes, and specialized functions like build, insert, and params_for to instantiate records.

Tokens
5.5K
Snippets
18
Records
26
Agent score
85%

What's inside ExMachina

  1. Define factories with ExMachina

    main

    You can define factories using ExMachina (for plain structs/maps) or ExMachina.Ecto (for Ecto schemas).

    Key Features in Factories:

    • Sequences: Use sequence/2 to generate unique values (e.g., emails or titles).
    • Derived Attributes: Define attributes based on other values. You can use a direct value or an anonymous function for lazy evaluation.
    • Associations: Use build(:factory_name) to define associations. If using ExMachina.Ecto, these associations are automatically inserted when you call insert.
    • Derived Factories: Create specialized versions of existing factories using struct!/2.
    defmodule MyApp.Factory do
      # with Ecto
      use ExMachina.Ecto, repo: MyApp.Repo
    
      # without Ecto
      use ExMachina
    
      def user_factory do
        %MyApp.User{
          name: "Jane Smith",
          email: sequence(:email, &"email-#{&1}@example.com"),
          role: sequence(:role, ["admin", "user", "other"]),
        }
      end
    
      def article_factory do
        title = sequence(:title, &"Use ExMachina! (Part #{&1})")
        slug = MyApp.Article.title_to_slug(title)
        %MyApp.Article{
          title: title,
          slug: slug,
          # lazy attribute via function
          tags: fn article ->
            if String.contains?(article.title, "Silly") do
              ["silly"]
            else
              []
            end
          end,
          # associations are inserted when you call `insert`
          author: build(:user)
        }
      end
    
      def featured_article_factory do
        struct!(
          article_factory(),
          %{
            featured: true,
          }
        )
      end
    end
  2. Define custom strategies with ExMachina.Strategy

    main

    You can extend factory behavior by defining custom strategies using ExMachina.Strategy. This allows you to add new methods to your factory module. For example, you can create a strategy to automatically JSON-encode your records.

    defmodule MyApp.JsonEncodeStrategy do
      use ExMachina.Strategy, function_name: :json_encode
    
      def handle_json_encode(record, _opts) do
        Poison.encode!(record)
      end
    end
    
    defmodule MyApp.Factory do
      use ExMachina
      # Using this will add json_encode/2, json_encode_pair/2 and json_encode_list/3
      use MyApp.JsonEncodeStrategy
    
      def user_factory do
        %User{name: "John"}
      end
    end
    
    # Will build and then return a JSON encoded version of the user.
    MyApp.Factory.json_encode(:user)
  3. Delay attribute evaluation with anonymous functions

    main

    Because build/2 is evaluated immediately, passing a direct call like build(:user) to an attribute in another factory will result in the same user being shared across multiple calls to the parent factory.

    To ensure a fresh record is created for every instance, pass an anonymous function instead. You can also pass the parent record into the function to create dependent attributes.

    Note: The parent record passed to the function is the struct after it is built, but before it is inserted (it will not have database-generated fields like id).

  4. Handle Ecto associations in factories

    main

    When using ExMachina with Ecto, calling insert functions will automatically save associations (including belongs_to, has_many, has_one, and embeds).

    To avoid performance issues and bugs caused by unnecessary database writes, you should use build/2 instead of insert/2 when declaring associations within your factory definitions.

    def article_factory do
      %Article{
        title: "Use ExMachina!",
        # associations are inserted when you call `insert`
        comments: [build(:comment)],
        author: build(:user),
      }
    end
  5. Configure compilation paths for test support

    main

    If your factory modules are located in test/support or a custom directory like test/factories, you must update your mix.exs to include these paths in the test environment so they compile correctly.

    def project do
      [
       app: ...,
       # Add this if it's not already in your project definition.
       elixirc_paths: elixirc_paths(Mix.env)
      ]
    end
    
    # This makes sure your factory and any other modules in test/support are compiled
    # when in the test environment.
    defp elixirc_paths(:test), do: ["lib", "test/support"]
    defp elixirc_paths(_), do: ["lib"]
  6. Install ExMachina

    main

    To use ExMachina, add it to your mix.exs dependencies. It is recommended to restrict it to the :test environment.

    After adding the dependency, ensure you start the application in your test/test_helper.exs before starting ExUnit:

    {:ok, _} = Application.ensure_all_started(:ex_machina)

    Non-Phoenix Projects

    If you are not using Phoenix and want to keep ExMachina only in the test environment, you must add test/support to your elixirc_paths in mix.exs to ensure your factory modules are compiled.

    def deps do
      [
        {:ex_machina, "~> 2.8.1", only: :test},
      ]
    end
  7. Organize and split factories into multiple files

    main

    For large projects, avoid putting all factories in one module. Instead, use a main factory module that includes specialized factory modules.

    1. Create a main module MyApp.Factory in test/support/factory.ex.
    2. Use use ExMachina.Ecto, repo: MyApp.Repo in the main module.
    3. Create individual factory modules in test/factories/*.ex.
    4. Use defmacro __using__ in the individual modules to allow them to be included in the main factory.
    5. Include the individual modules in the main factory using use.

    If you place factories in a custom directory like test/factories, ensure you add that path to elixirc_paths in mix.exs.

    # test/support/factory.ex
    defmodule MyApp.Factory do
      use ExMachina.Ecto, repo: MyApp.Repo
      use MyApp.ArticleFactory
    end
    
    # test/factories/article_factory.ex
    defmodule MyApp.ArticleFactory do
      defmacro __using__(_opts) do
        quote location: :keep do
          def article_factory do
            %MyApp.Article{
              title: "My awesome article!",
              body: "Still working on it!"
            }
          end
        end
      end
    end
  8. Configure factory directory for Phoenix projects

    main

    By default, ExMachina expects factories to be in test/support. If you want to store your factories in a different directory (e.g., test/factories), update your mix.exs file to include that path in the elixirc_paths configuration for the :test environment.

    # Add the folder to the end of the list. In this case we're adding `test/factories`.
    defp elixirc_paths(:test), do: ["lib", "test/support", "test/factories"]
  9. Generated helper functions for strategies

    main

    When you use ExMachina.Strategy with a function_name, several helper functions are automatically generated in your factory module:

    • function_name(factory_name, attrs, opts \ %{}): Builds a record and then applies the strategy.
    • function_name(factory_name, attrs): Builds a record and then applies the strategy.
    • function_name(factory_name): Builds a record with empty attributes and then applies the strategy.
    • function_name_pair(factory_name, attrs, opts \ %{}): Generates a list of 2 records using the strategy.
    • function_name_list(number_of_records, factory_name, attrs, opts): Generates a list of number_of_records using the strategy.
  10. How to create a custom ExMachina strategy

    main

    You can create custom strategies by defining a module that uses ExMachina.Strategy. A strategy allows you to add new helper functions to your factories that perform operations on built records (e.g., JSON encoding, database insertion, or transformation).

    Implementation Steps

    1. Define the strategy module: Use ExMachina.Strategy and provide a function_name option. This name determines the name of the generated function in your factory.
    2. Implement the handler: Define a function named handle_#{function_name}. This function is responsible for the actual logic.
    3. Use the strategy in a factory: In your factory module, use both ExMachina and your custom strategy module. You can pass configuration options to the strategy via use.

    Handler Arguments

    The handle_#{function_name} function receives three arguments:

    1. record: The built record (struct or map).
    2. opts: A map containing the options passed when the strategy was used, merged with %{factory_module: FactoryModule}.
    3. function_opts: (Optional) The options passed directly to the generated function call.
    defmodule MyApp.JsonEncodeStrategy do
      use ExMachina.Strategy, function_name: :json_encode
    
      # Handler with 2 arguments
      def handle_json_encode(record, %{encoder: encoder}) do
        encoder.encode!(record)
      end
    
      # Handler with 3 arguments (includes function-level options)
      def handle_json_encode(record, %{encoder: encoder}, encoding_opts) do
        encoder.encode!(record, encoding_opts)
      end
    end
    
    defmodule MyApp.JsonFactory do
      use ExMachina
      use MyApp.JsonEncodeStrategy, encoder: Poison
    
      def user_factory do
        %User{name: "John"}
      end
    end
    
    # Usage:
    # Returns a JSON encoded version of the user
    MyApp.JsonFactory.json_encode(:user)
  11. Enable Ecto persistence in ExMachina factories

    main

    To use insert/1 within your ExMachina factories to persist records directly to your database, you must use the ExMachina.Ecto module and provide your application's Repo.

    If you do not provide the :repo option, calling insert/1 will raise an error: insert/1 is not available unless you provide the :repo option. Example: use ExMachina.Ecto, repo: MyApp.Repo.

    use ExMachina.Ecto, repo: MyApp.Repo
  12. Use ExMachina in a module

    main

    To use ExMachina in your test factories, include use ExMachina in your module. This automatically imports several helper functions and sets up the factory infrastructure.

    When you use ExMachina, the following functions are imported into your module:

    • sequence/1, sequence/2, sequence/3
    • merge_attributes/2
    • evaluate_lazy_attributes/1

    It also provides the build/1, build/2, build_pair/1, build_pair/2, build_list/3, and build_list/4 functions to generate data based on your defined factories.