Features, described gradually

There's a form-handling workflow that I'm going to call "Ecto Classic":

  1. Form data (strings) is given to code that converts it into Elixir values (like integers or dates) and also validates the results.

  2. If the resulting changeset is marked invalid, the user is given another chance to fill in the form.

  3. Otherwise, the changeset encapsulates values to be inserted into a database or used to update an existing row.

  4. However, the database might complain about violated constraints like an attempt to duplicate a unique field or violate optimistic locking.

  5. If a constraint is violated, the user is given another chance to fix the problem.

  6. Otherwise, the task is done (though the results of the database update might be displayed to the user).

The code under test

I'll start with an absurdly simple Ecto Schema:

defmodule App.Schemas.Basic do
  use Ecto.Schema
  import Ecto.Changeset

  embedded_schema do
    field :age, :integer
    field :date, :date
  end

  def changeset(struct, params) do
    struct
    |> cast(params, [:age, :date])
    |> validate_required([:age, :date])
  end
end

It has two fields, :age and :date. Each is processed ("cast", line 12) by transformations built into Ecto. The result is a Changeset structure. It will be marked as invalid if the :age is a string that's not an integer or the :date is not in proper ISO8601 format. Beyond that, all that's required is that each field have some value. (Without that, a missing or empty field would be allowed.)

All the code in this tutorial can be found at https://github.com/marick/tts_ecto_example.

A simple example module

An example module holds a series of examples, each of which is (for the moment) just a way of writing input parameters. The module starts like this:

defmodule Examples.Schemas.Basic.Simplest do
  alias App.Schemas.Basic, as: Basic
  use TransformerTestSupport.Variants.EctoClassic

  def create_test_data do 
    start(
      module_under_test: Basic,
      format: :phoenix
    ) |> 

The module names the module under test (line 2) and notes that it's assumed to use the Ecto Classic workflow variant (line 3). There are other workflows. For example, I use a view-model-style workflow that extends the Ecto Classic workflow. It has its own variant.

The create_test_data function is called (using reflection) to create a big test data structure. The starting step (line 6) is to note the module under test (so that its functions, like Basic.changeset , can be called). It also describes the :format of the parameters, which will come into play shortly.

Let's create examples that represent both successful validation and validation failure. (We'll leave aside constraint-checking and use of the database for now.) That looks like this:

    category(                   :success,
      ok: [
        params(
          age: 1,
          date: "2001-01-01")
      ]) |> 

    category(                   :validation_failure,
      invalid: [
        params(
          age: "32a",
          date: "2001-1-1"),
      ],
      empty: [
        params(
          age: "",
          date: ""),
      ],
      missing: [params()]
    )      
  end

For now, you can consider the categories purely for documentation, but they'll have a bigger role to play later.

The four examples each describe parameters to be checked by Basic.changeset. Notice that the parameters are described as Elixir values, rather than the strings you'd get from HTTP. That rarely matters, in that Elixir's cast is perfectly happy casting either the string "1" or the integer 1 to a Schema's :integer field, and it doesn't care whether that value belongs to the key "age" or the key :age.

However, I'm a stickler for realism, so the global format: :phoenix option converts the parameters into the same format Phoenix would deliver to one of its controllers. When called, Basic.changeset would actually receive %{"age" => "1", "date" => "2001-01-01"}.

What that gives you

You could now type the following:

  $ MIX_ENV=test iex -S mix
  iex> TransformerTestSupport.start
  iex> alias Examples.Schemas.Basic.SimplestParams.Tester
  iex> Tester.params(:ok)
  %{"age" => "1", "date" => "2001-01-01"}

That's not very exciting, though it's interesting that you've gotten a Basic.SimplestParams.Tester module that contains some test-support functions like params (line 4).

We could use those params in tests:

defmodule App.Schemas.Basic.SimplestParamsTest do
  use App.Case
  alias App.Schemas.Basic, as: Schema
  alias Examples.Schemas.Basic.Simplest.Tester

  test "valid dates are accepted" do
    Schema.changeset(%Schema{}, Tester.params(:ok))
    |> assert_valid
    |> assert_changes(age: 1,
                      date: ~D[2001-01-01])
  end

(The test uses the "flow-style" assertions from ecto_flow_assertions.)

That's fine, but it doesn't gain much. In fact, it's arguably worse than describing the parameters in the test, because the input values are visually far separated from the expected results. So let's move the expected results.

Last updated