Validating your Ecto Models with ValidField

By: Dan McClain

When we were working with Rails, we would unit test our validations with a libary called ValidAttribute. This library would allow you to specify the attribute and a list of values then check if the values yield errors or not. On a recent client project, I resurrected the pattern and extracted it as a Phoenix library this weekend.

Introducing ValidField

Let’s import ValidField and get right to the tests:

defmodule App.UserTest do
  import ValidField
  use ExUnit.Case
  alias App.User

  test ".changeset - Validations" do
    with_changeset(%User{})
    |> assert_valid_field(:email, ["something@else.com"])
    |> assert_invalid_field(:email, ["", nil, "test"])
    |> assert_valid_field(:password, ["password123!"])
    |> assert_invalid_field(:password, [nil, "", "test", "nospecialcharacters1", "nonumber!"])
  end

  test ".changeset - Validations - complex changeset" do
    with_changeset(%User{}, fn (model, params) -> App.UserController.changeset(model, params, :insert))
    |> assert_valid_field(:email, ["something@else.com"])
  end
end

First, we use with_changeset/1, which takes the model struct as the sole argument and returns a map that contains an anonymous function that yields a changeset from Model.changeset. with_changeset/1 assumes that your changeset is defined at Model.changeset/2. If your changeset is defined elsewhere or has additional arguments, you’ll want to use with_changeset/2. The first argument of with_changeset/2 is still the model struct, but the second argument is a function with an arity of 2. The first argument to the function will be the model struct passed in, the second argument will be a map of field values to be set in the changeset.

After we have a changeset map, we pass that as the first argument to assert_valid_field/3 and assert_invalid_field/3. Instead of returning a boolean of whether or not the field is valid for the list of values passed in, these functions run the assertions internally. This is done to provide useful testing errors when running mix test. Assume that you inverted the third line of the test to be the following (and didn’t change your validations), the following error will be generated:

defmodule App.UserTest do
  import ValidField
  use ExUnit.Case
  alias App.User

  test ".changeset - Validations" do
    with_changeset(%User{})
    |> assert_valid_field(:email, ["something@else.com"])
    |> assert_valid_field(:email, ["", nil, "test"])
    # (ExUnit.AssertionError) Expected the following values to be valid for "email": nil, "", "tests"
  end
end

OK, I see what you did there but why?

Clean workflow for unit testing changesets

By grouping all the valid and invalid cases in your tests, you can quickly understand what makes your changeset valid. It also allows you to update your tests by just adding another value to either function call. Say you want to stop accepting Gmail address as valid email address; you just add some-email@gmail.com to your assert_invalid_field call for email, and update the tests to satisfy this new requirement. We aren’t worried about the error message anymore.

Less brittle tests

Most unit tests around changeset validations use the error_on function and assert that the field and a specific error message are contained in the list of errors provided. This is a decent starting point, but has a couple of drawbacks. The first is that your test is tied directly to the error message, meaning that changing a validation message requires you to update your test. A correction to a gramatical error would cause a test failure, showing how brittle this pattern could be. What if you support multiple languages? Since your error messages might be different for an email that contains a space or one that doesn’t contain a valid domain, your tests will be more verbose since the messages need to be matched individually.

With ValidField, you are testing the behavior of your changeset, rather than the implementation of your error messages.

Go forth and test your changeset

Making sure your changeset is properly defined is important, and ValidField makes it much easier to unit test them. Having the list of valid and invalid values for your field in your tests also serves a documentation of what should be accepted for a given field as well.