tdd-cycle
SKILL.md
TDD Cycle — Minitest + Fixtures
Project Conventions
- Testing: Minitest + fixtures (NEVER RSpec or FactoryBot)
- Components: ViewComponents for reusable UI (partials OK for simple one-offs)
- Authorization: Pundit policies (deny by default)
- Jobs: Solid Queue, shallow jobs,
_later/_nownaming - Frontend: Hotwire (Turbo + Stimulus) + Tailwind CSS
- State: State-as-records for business state (booleans only for technical flags)
- Architecture: Rich models first, service objects for multi-model orchestration
- Routing: Everything-is-CRUD (new resource over new action)
- Quality: RuboCop (omakase) + Brakeman
The Cycle
1. RED → Write a failing test that describes desired behavior
2. GREEN → Write the minimum code to pass the test
3. REFACTOR → Improve code while keeping tests green
4. REPEAT → Next behavior
Workflow Checklist
TDD Progress:
- [ ] Step 1: Understand the requirement
- [ ] Step 2: Choose test type (model/controller/system/component)
- [ ] Step 3: Write failing test (RED)
- [ ] Step 4: Verify test fails correctly
- [ ] Step 5: Implement minimal code (GREEN)
- [ ] Step 6: Verify test passes
- [ ] Step 7: Refactor if needed
- [ ] Step 8: Verify tests still pass
Step 1: Requirement Analysis
Before writing any code, understand:
- What is the expected input?
- What is the expected output/behavior?
- What are the edge cases?
- What errors should be handled?
Step 2: Choose Test Type
| Test Type | Use For | Location |
|---|---|---|
| Model test | Validations, scopes, instance methods | test/models/ |
| Controller test | HTTP flow, authorization, responses | test/controllers/ |
| System test | Full user flows with JavaScript | test/system/ |
| Service test | Business logic, complex operations | test/services/ |
| Query test | Complex queries, correctness | test/queries/ |
| Component test | ViewComponent rendering | test/components/ |
| Policy test | Pundit authorization rules | test/policies/ |
| Job test | Background job behavior | test/jobs/ |
| Mailer test | Email content, recipients | test/mailers/ |
Step 3: Write Failing Test (RED)
Model Test Template
# test/models/post_test.rb
require "test_helper"
class PostTest < ActiveSupport::TestCase
setup do
@post = posts(:published)
end
test "requires title" do
@post.title = nil
assert_not @post.valid?
assert_includes @post.errors[:title], "can't be blank"
end
test ".recent returns posts in descending order" do
recent = posts(:recent)
old = posts(:old)
assert_equal [recent, old], Post.recent.to_a
end
test "#publish! creates a publication record" do
post = posts(:draft)
assert_difference "Publication.count", 1 do
post.publish!(user: users(:admin))
end
end
end
Controller (Integration) Test Template
# test/controllers/posts_controller_test.rb
require "test_helper"
class PostsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:one)
@post = posts(:one)
sign_in_as @user
end
test "should get index" do
get posts_url
assert_response :success
end
test "should create post" do
assert_difference("Post.count") do
post posts_url, params: { post: { title: "New Post", body: "Content" } }
end
assert_redirected_to post_url(Post.last)
end
test "should not create post with invalid params" do
assert_no_difference("Post.count") do
post posts_url, params: { post: { title: "", body: "" } }
end
assert_response :unprocessable_entity
end
test "requires authentication" do
sign_out
get posts_url
assert_redirected_to new_session_url
end
end
Service Test Template
# test/services/orders/create_service_test.rb
require "test_helper"
class Orders::CreateServiceTest < ActiveSupport::TestCase
setup do
@user = users(:one)
@product = products(:widget)
@service = Orders::CreateService.new
end
test "creates order with valid params" do
result = @service.call(user: @user, items: [{ product_id: @product.id, quantity: 2 }])
assert result.success?
assert_kind_of Order, result.data
assert_equal @user, result.data.user
end
test "returns failure with empty items" do
result = @service.call(user: @user, items: [])
assert result.failure?
assert_equal :empty_cart, result.code
end
test "wraps in transaction" do
assert_no_difference "Order.count" do
@service.call(user: @user, items: [{ product_id: 0, quantity: 1 }])
end
end
end
System Test Template
# test/system/posts_test.rb
require "application_system_test_case"
class PostsTest < ApplicationSystemTestCase
setup do
@user = users(:one)
sign_in_as @user
end
test "creating a post" do
visit new_post_url
fill_in "Title", with: "My Post"
fill_in "Body", with: "Post content here"
click_button "Create Post"
assert_text "Post created successfully"
assert_text "My Post"
end
test "shows validation errors" do
visit new_post_url
click_button "Create Post"
assert_text "can't be blank"
end
end
ViewComponent Test Template
# test/components/status_badge_component_test.rb
require "test_helper"
class StatusBadgeComponentTest < ViewComponent::TestCase
test "renders published badge" do
render_inline(StatusBadgeComponent.new(status: :published))
assert_selector ".badge", text: "Published"
assert_selector ".bg-green-100"
end
test "renders draft badge" do
render_inline(StatusBadgeComponent.new(status: :draft))
assert_selector ".badge", text: "Draft"
assert_selector ".bg-gray-100"
end
end
Policy Test Template
# test/policies/post_policy_test.rb
require "test_helper"
class PostPolicyTest < ActiveSupport::TestCase
setup do
@owner = users(:one)
@other = users(:two)
@post = posts(:one) # belongs to @owner
end
test "owner can update" do
assert PostPolicy.new(@owner, @post).update?
end
test "non-owner cannot update" do
assert_not PostPolicy.new(@other, @post).update?
end
test "scope returns only authorized records" do
scope = PostPolicy::Scope.new(@owner, Post).resolve
assert_includes scope, @post
end
end
Step 4: Verify Failure
Run the test:
bin/rails test test/models/post_test.rb --verbose
The test MUST fail with a clear error. If it passes immediately, either:
- The behavior already exists
- The test isn't testing what you think
Step 5: Implement (GREEN)
Write the MINIMUM code to pass:
- No optimization
- No edge case handling beyond what's tested
- No refactoring
- Just make it work
Step 6: Verify Pass
bin/rails test test/models/post_test.rb --verbose
Step 7: Refactor
Improve code while tests stay green:
- Extract methods for clarity
- Improve naming
- Remove duplication
- Simplify logic
Rule: Make ONE change at a time, run tests after EACH change.
Step 8: Final Verification
Run all related tests:
bin/rails test
Fixtures Best Practices
# test/fixtures/posts.yml
published:
title: Published Post
body: This is published content
user: one
created_at: <%= 1.day.ago %>
draft:
title: Draft Post
body: This is draft content
user: one
recent:
title: Recent Post
body: Recent content
user: one
created_at: <%= 1.hour.ago %>
old:
title: Old Post
body: Old content
user: two
created_at: <%= 1.year.ago %>
Fixture naming tips:
- Use descriptive names:
published,draft,admin_post - Reference other fixtures by name:
user: one - Use ERB for dynamic values:
<%= Time.current %>
Test Helper Patterns
# test/test_helper.rb
class ActiveSupport::TestCase
# Use fixtures for all tests
fixtures :all
# Authentication helper
def sign_in_as(user)
post session_url, params: { email: user.email, password: "password" }
end
def sign_out
delete session_url
end
end
Anti-Patterns to Avoid
- Testing implementation, not behavior — Test what it does, not how
- Too many assertions per test — One concept per test
- Brittle tests — Don't assert exact timestamps or error messages
- Slow tests — Prefer model tests over system tests when possible
- Skipping the RED step — Always see it fail first
- Over-mocking — Use real objects with fixtures when possible
Weekly Installs
2
Repository
dchuk/rails_ai_agentsFirst Seen
7 days ago
Security Audits
Installed on
opencode2
gemini-cli2
antigravity2
claude-code2
windsurf2
codex2