How to Monitor ExMachina with Vigilmon
ExMachina is the standard factory library for Elixir and Ecto. It simplifies test data creation by defining reusable factory modules — with traits, sequences, and associations — so tests can say insert!(:user) or build(:post, published: true) instead of manually constructing structs or calling Repo.insert!/1 with hand-crafted changesets.
When ExMachina works, tests are clean and isolated. When factories break — an Ecto schema changes without updating the factory, an association factory is removed, or a sequence overflows — tests fail with cryptic database errors rather than clear fixture errors, and CI becomes hard to diagnose. Vigilmon heartbeats paired with factory health checks catch these regressions before they slow down your team.
Why Monitor ExMachina?
Factory regressions surface as test failures that look like application bugs:
- Schema drift — a field is added to an Ecto schema with a
NOT NULLconstraint; the factory still uses the old fields; every test that inserts that schema fails with a database constraint error - Broken associations — a factory's
belongs_toassociation references another factory that was renamed or deleted;build_list(:post)raises aKeyErrororFunctionClauseError - Sequence overflow — a factory uses a sequence for a unique integer field; after enough test runs, the sequence counter hits database
INToverflow and inserts fail - Trait conflicts — two traits set conflicting field values; a test using both traits silently gets unpredictable data
- Missing required fields — a changeset adds a new required field; the factory does not include it; tests pass because
build/2skips changeset validation, butinsert!/1fails - Cascade delete gaps — a factory creates associations that leave orphan records when the primary record is deleted; Ecto raises a foreign-key constraint error in teardown
Key Metrics to Track
| Metric | What it tells you |
|--------|-------------------|
| Factory build error rate | Whether build/2 raises for any registered factory |
| Factory insert error rate | Whether insert!/1 succeeds for all factories |
| Association traversal success | Whether nested associations build without error |
| Trait smoke test pass rate | Whether each trait produces valid changesets |
| CI heartbeat | Whether factory health checks ran recently |
| Orphan record count | Whether teardown leaves dangling association rows |
| Sequence headroom | How close sequence counters are to overflow |
Step 1: Add ExMachina to Your Project
# mix.exs
defp deps do
[
{:ex_machina, "~> 2.8", only: :test},
{:ecto_sql, "~> 3.11"},
# rest of your deps
]
end
Step 2: Define Your Factory Module
# test/support/factory.ex
defmodule MyApp.Factory do
use ExMachina.Ecto, repo: MyApp.Repo
def user_factory do
%MyApp.Accounts.User{
first_name: sequence(:first_name, &"User#{&1}"),
last_name: "Smith",
email: sequence(:email, &"user#{&1}@example.com"),
role: :member,
confirmed_at: DateTime.utc_now() |> DateTime.truncate(:second),
}
end
def post_factory do
%MyApp.Blog.Post{
title: sequence(:title, &"Post Title #{&1}"),
body: "Default post body for testing.",
published: false,
author: build(:user),
}
end
def comment_factory do
%MyApp.Blog.Comment{
body: "Test comment.",
post: build(:post),
author: build(:user),
}
end
# Traits
def published_post_factory do
struct!(
post_factory(),
published: true,
published_at: DateTime.utc_now() |> DateTime.truncate(:second)
)
end
def admin_user_factory do
struct!(user_factory(), role: :admin)
end
end
Step 3: Write a Factory Health Check Mix Task
# lib/mix/tasks/check_factories.ex
defmodule Mix.Tasks.CheckFactories do
use Mix.Task
@shortdoc "Validate all ExMachina factories build without error"
@factories [
:user,
:post,
:comment,
:published_post,
:admin_user,
]
def run(_args) do
Mix.Task.run("app.start")
build_results =
Enum.map(@factories, fn factory ->
try do
MyApp.Factory.build(factory)
{factory, :build_ok}
rescue
e -> {factory, {:build_error, Exception.message(e)}}
end
end)
failed_builds =
Enum.filter(build_results, fn {_f, status} -> status != :build_ok end)
if failed_builds == [] do
Mix.shell().info("All #{length(@factories)} factories built successfully")
else
Enum.each(failed_builds, fn {factory, {:build_error, msg}} ->
Mix.shell().error("BUILD FAIL :#{factory} — #{msg}")
end)
exit({:shutdown, 1})
end
end
end
Step 4: Write Factory Tests
# test/support/factory_test.exs
defmodule MyApp.FactoryTest do
use MyApp.DataCase, async: false
import MyApp.Factory
describe "build/1 — all factories" do
test "user factory builds a valid struct" do
user = build(:user)
assert %MyApp.Accounts.User{} = user
assert is_binary(user.email)
assert String.contains?(user.email, "@")
end
test "post factory builds with association" do
post = build(:post)
assert %MyApp.Blog.Post{} = post
assert %MyApp.Accounts.User{} = post.author
end
test "comment factory builds with nested associations" do
comment = build(:comment)
assert %MyApp.Blog.Comment{} = comment
assert %MyApp.Blog.Post{} = comment.post
assert %MyApp.Accounts.User{} = comment.author
end
end
describe "insert!/1 — all factories" do
test "user inserts without database error" do
user = insert!(:user)
assert user.id != nil
end
test "post inserts with associated user" do
post = insert!(:post)
assert post.id != nil
assert post.author_id != nil
end
test "comment inserts with associated post and user" do
comment = insert!(:comment)
assert comment.id != nil
assert comment.post_id != nil
assert comment.author_id != nil
end
end
describe "traits" do
test "published_post trait sets published: true" do
post = build(:published_post)
assert post.published == true
assert post.published_at != nil
end
test "admin_user trait sets role: :admin" do
user = build(:admin_user)
assert user.role == :admin
end
test "published_post inserts successfully" do
post = insert!(:published_post)
assert post.id != nil
assert post.published == true
end
end
describe "sequences" do
test "email sequences generate unique values" do
emails = Enum.map(1..20, fn _ -> build(:user).email end)
assert length(Enum.uniq(emails)) == 20, "Email sequence produced duplicates"
end
test "title sequences generate unique values" do
titles = Enum.map(1..20, fn _ -> build(:post).title end)
assert length(Enum.uniq(titles)) == 20, "Title sequence produced duplicates"
end
end
describe "params_for/1" do
test "user params_for produces a valid changeset" do
params = params_for(:user)
changeset = MyApp.Accounts.User.changeset(%MyApp.Accounts.User{}, params)
assert changeset.valid?
end
end
end
Step 5: Run Factory Tests in CI with a Heartbeat
# .github/workflows/factory-health.yml
name: ExMachina Factory Health
on:
push:
branches: [main]
schedule:
- cron: '0 7 * * *' # daily at 07:00 UTC
jobs:
factory-health:
name: Factory Smoke Test
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: my_app_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
MIX_ENV: test
DATABASE_URL: postgres://postgres:postgres@localhost:5432/my_app_test
VIGILMON_EXMACHINA_HEARTBEAT_URL: ${{ secrets.VIGILMON_EXMACHINA_HEARTBEAT_URL }}
steps:
- uses: actions/checkout@v4
- uses: erlef/setup-beam@v1
with:
elixir-version: '1.17'
otp-version: '27'
- name: Cache deps
uses: actions/cache@v3
with:
path: deps
key: ${{ runner.os }}-deps-${{ hashFiles('mix.lock') }}
- run: mix deps.get
- name: Setup database
run: mix ecto.create && mix ecto.migrate
- name: Factory build smoke test
run: mix check_factories
- name: Factory ExUnit tests
run: mix test test/support/factory_test.exs --color
- name: Ping Vigilmon heartbeat
if: success()
run: curl -fsS "$VIGILMON_EXMACHINA_HEARTBEAT_URL"
Step 6: Create a Heartbeat Monitor in Vigilmon
- Sign in at vigilmon.online
- Click New Monitor → Heartbeat
- Name it
ExMachina Factory Health — main branch - Set the expected interval to 25 hours (matches the daily CI schedule)
- Copy the URL and store it as
VIGILMON_EXMACHINA_HEARTBEAT_URLin CI secrets
If a factory breaks — because an Ecto schema changed, an association was removed, or a sequence-based field overflowed — CI exits non-zero and no heartbeat fires. Vigilmon alerts you within the missed-interval window.
Step 7: Track Schema Drift
Add a schema-drift check to catch field mismatches between your Ecto schemas and factory definitions before they cause test failures:
# test/support/factory_schema_test.exs
defmodule MyApp.FactorySchemaTest do
use ExUnit.Case, async: true
import MyApp.Factory
@schemas [
{MyApp.Accounts.User, :user},
{MyApp.Blog.Post, :post},
{MyApp.Blog.Comment, :comment},
]
describe "factory fields match schema fields" do
for {schema, factory} <- @schemas do
@schema schema
@factory factory
test "#{factory} factory covers all non-virtual, non-association fields" do
schema_fields =
@schema.__schema__(:fields)
|> Enum.reject(fn f -> f in [:id, :inserted_at, :updated_at] end)
built = build(@factory)
built_map = Map.from_struct(built)
missing =
Enum.filter(schema_fields, fn field ->
!Map.has_key?(built_map, field) or Map.get(built_map, field) == nil
end)
assert missing == [],
"#{@factory} factory missing or nil for schema fields: #{inspect(missing)}"
end
end
end
end
Step 8: Alerting
In Vigilmon, go to Notifications → New Channel → Slack:
Heartbeat missed:
🔴 MISSED: ExMachina Factory Health — main branch
Last healthy factory run: 30 hours ago
Action: Check CI — schema drift, broken association, or factory crash
What You Built
| What | How | |------|-----| | Factory module | ExMachina factories with traits, sequences, associations | | Build smoke test | Mix task: validates all factories build without error | | Insert tests | ExUnit: validates database inserts for all factories | | Trait tests | ExUnit: validates each trait produces the right fields | | Sequence uniqueness | ExUnit: checks sequences generate unique values | | Schema drift check | ExUnit: compares factory fields against Ecto schema | | CI integration | GitHub Actions daily schedule with Postgres service | | CI heartbeat | Vigilmon heartbeat — alerts when factory health misses | | Slack alerting | Vigilmon Slack notification channel |
ExMachina keeps your test data clean and consistent. Vigilmon ensures ExMachina keeps working.
Next Steps
- Add
params_for/2changeset tests for all factories to catch required-field drift early - Use
build_pair/2andbuild_list/3to test pagination, sorting, and list endpoints - Combine ExMachina with StreamData generators for property-based factory testing
- Set up a Vigilmon status page for your QA team showing factory health over time
Get started free at vigilmon.online.