MANDATORY for ANY feature or bugfix - write ExUnit test FIRST, watch it FAIL, then implement. NO exceptions. Use before writing any Elixir production code.
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
Not sometimes. Not usually. ALWAYS.
If you write production code before a failing test, DELETE IT and start over.
If you're changing .ex files in lib/, this skill is MANDATORY.
Write ONE minimal ExUnit test
test "creates user with valid attrs" do
attrs = %{name: "Alice", email: "alice@example.com"}
assert {:ok, %User{} = user} = Accounts.create_user(attrs)
assert user.name == "Alice"
assert user.email == "alice@example.com"
end
Run the test
mix test test/my_app/accounts_test.exs:42
VERIFY IT FAILS FOR THE RIGHT REASON
CHECKPOINT: If test doesn't fail, delete it and write a different test.
Write SIMPLEST code to pass the test
def create_user(attrs) do
%User{}
|> User.changeset(attrs)
|> Repo.insert()
end
Run the test again
mix test test/my_app/accounts_test.exs:42
VERIFY IT PASSES
CHECKPOINT: If test doesn't pass, fix implementation (not test).
Improve code quality
Run tests after EACH change
mix test
Stay GREEN
CHECKPOINT: Tests must stay green throughout refactoring.
Before claiming you're done, verify:
If you can't check ALL boxes, you didn't follow TDD.
Response: NO. Delete the code. Write test first.
Response: WRONG. Even simple code needs failing tests. Write test, watch fail.
Response: Irrelevant. Write it first anyway.
Response: Delete both. Write test, watch fail, then implement.
Response: RED FLAG. Test might not be testing anything. Review test.
Response: Correct - but ALL existing tests must stay GREEN.
# RED: Write test first
test "list_users/0 returns all users" do
user1 = fixture(:user)
user2 = fixture(:user)
users = Accounts.list_users()
assert length(users) == 2
assert user1 in users
assert user2 in users
end
# Run test → watch it fail (function doesn't exist)
# GREEN: Implement
def list_users do
Repo.all(User)
end
# Run test → watch it pass
# RED: Write test for validation
test "changeset with invalid email" do
changeset = User.changeset(%User{}, %{email: "invalid"})
refute changeset.valid?
assert %{email: ["invalid format"]} = errors_on(changeset)
end
# Run test → watch it fail
# GREEN: Add validation
def changeset(user, attrs) do
user
|> cast(attrs, [:email])
|> validate_format(:email, ~r/@/)
end
# RED: Write test
test "GET /users returns 200", %{conn: conn} do
conn = get(conn, ~p"/users")
assert html_response(conn, 200)
end
# Run test → watch it fail (route doesn't exist)
# GREEN: Add route and controller action
If Dialyzer reports an error:
mix dialyzer to verifyNEVER:
The test proves it works. The spec helps Dialyzer understand.
If Credo reports a warning:
mix credo to verifyNEVER:
# credo:disable-for-this-fileCredo is helping you write better code. Listen to it.
TDD feels slow at first. That's because you're used to:
TDD is actually faster because:
Before writing ANY Elixir production code, ask:
If any answer is NO → write the test first.
"Tests that pass on the first run might not be testing anything."
"Code without a failing test first is guess-driven development."
"TDD is slow. Debugging untested code is slower."
RED → GREEN → REFACTOR
Not GREEN → RED → "oops"
Not WRITE → PRAY → DEBUG
RED → GREEN → REFACTOR
Every. Single. Time.