How I Stopped Testing Everything Wrong by Understanding Message Types
My team was struggling with inconsistent tests that seemed to test too much or too little, but we couldn’t figure out why.
You know that moment when you realize half your test suite is asserting the wrong things because nobody taught you the fundamental difference between testing state changes and testing message passing? This was mine.
In this issue, I’ll take you through the message classification system that transformed how we approach testing. No theory dumps – just real code, real problems, and real solutions.
🚀 What you’ll learn:
How to identify incoming vs outgoing command messages
When to test state changes vs message expectations
Why mixing up these patterns creates fragile tests
How proper message classification prevents over-testing and under-testing
The Problem
We had confused tests that mixed different assertion patterns, making our test suite both brittle and unreliable.
Here’s what we were dealing with:
# Confused test - mixing state and message assertions
RSpec.describe User, type: :model do
describe '#activate!' do
let(:user) { User.create!(email: '[email protected]', active: false) }
it 'activates the user and logs the event' do
# Testing internal state change
expect { user.activate! }.to change { user.active }.from(false).to(true)
# Also testing external message sending (wrong!)
expect(AuditLogger).to have_received(:log).with('user_activated', user.id)
# And testing implementation details (also wrong!)
expect(user).to have_received(:update!).with(active: true, activated_at: anything)
end
end
end
💡 Warning Signs:
Tests asserting both state changes and message expectations in the same test
Fragile tests that broke when implementation changed
Over-mocked tests that didn’t actually verify behavior
Confusion about what each test was actually validating
The Journey: From Problem to Solution
Step 1: Understanding Command Message Types
The Core Insight: Command messages create side-effects, but the location of those side-effects determines how you should test them.
Two Types:
Incoming Command Messages → side-effects handled directly by the object under test
Outgoing Command Messages → side-effects delegated to collaborators
Before:
# Mixed-up testing approach
it 'activates user and publishes event' do
expect(user).to receive(:update!).with(active: true) # Wrong - testing implementation
expect(EventBus).to receive(:publish) # Wrong category - should be separate test
user.activate!
expect(user).to be_active # Right assertion, wrong place
end
After:
# Clear separation of concerns
describe '#activate!' do
it 'changes user state to active' do
user.activate!
expect(user).to be_active
expect(user.activated_at).to be_within(1.second).of(Time.current)
end
end
🎯 Impact:
Tests became focused on single responsibilities
Brittle mocking decreased dramatically
Test failures pointed to actual problems, not implementation changes
Step 2: Properly Testing Outgoing Command Messages
The key realization was that outgoing commands don’t change the sender object - they delegate responsibility to collaborators.
Incoming Command Example:
# app/models/user.rb
class User < ActiveRecord::Base
def activate!
update!(active: true, activated_at: Time.current)
end
end
# Test the direct side-effect on the object
RSpec.describe User do
describe '#activate!' do
let(:user) { User.create!(email: '[email protected]', active: false) }
it 'activates the user' do
user.activate!
expect(user).to be_active
expect(user.activated_at).to be_within(1.second).of(Time.current)
end
end
end
Outgoing Command Example:
# app/models/invoice.rb
class Invoice < ActiveRecord::Base
TOPIC_NAME = 'invoices'
include Phobos::Producer
def publish_event!(account_id:, year:, month:)
publish(
topic: TOPIC_NAME,
payload: {
account_id: account_id,
year: year,
month: month
}.to_json,
partition_key: account_id.to_s
)
end
end
# Test that the message was sent to the collaborator
RSpec.describe Invoice do
describe '#publish_event!' do
let(:invoice) { Invoice.create!(account_id: 123, year: 2025, month: 9) }
it 'publishes to Kafka' do
expect(invoice).to receive(:publish).with(
topic: 'invoices',
payload: { account_id: 123, year: 2025, month: 9 }.to_json,
partition_key: '123'
)
invoice.publish_event!(account_id: 123, year: 2025, month: 9)
end
end
end
The Aha Moment
The breakthrough came when we realized we were testing the wrong contracts.
For incoming commands, the contract is: “This object’s state will change in a specific way.” For outgoing commands, the contract is: “This collaborator will receive a specific message.”
Real Numbers From This Experience
Before: 40% of our tests were mixing assertion patterns
After: Clear separation with focused, reliable tests
Test maintenance: 60% reduction in brittle test failures
Debug time: Cut test debugging time from hours to minutes
The Final Result
# Clean command message testing patterns
# Incoming Command: Test direct side-effects
class BankAccount
def deposit(amount)
self.balance += amount
self.last_deposit_at = Time.current
save!
end
end
# Test the object's state change
RSpec.describe BankAccount do
describe '#deposit' do
let(:account) { BankAccount.create!(balance: 100) }
it 'increases the balance and records timestamp' do
account.deposit(50)
expect(account.balance).to eq(150)
expect(account.last_deposit_at).to be_within(1.second).of(Time.current)
end
end
end
# Outgoing Command: Test message sending
class OrderProcessor
def fulfill_order(order)
inventory_service.reserve_items(order.items)
shipping_service.create_shipment(order)
notification_service.send_confirmation(order.customer_email)
end
end
# Test that collaborators receive the right messages
RSpec.describe OrderProcessor do
describe '#fulfill_order' do
let(:order) { double('order', items: ['item1'], customer_email: '[email protected]') }
let(:processor) { OrderProcessor.new }
before do
allow(processor).to receive(:inventory_service) { double('inventory') }
allow(processor).to receive(:shipping_service) { double('shipping') }
allow(processor).to receive(:notification_service) { double('notifications') }
end
it 'coordinates with all required services' do
expect(processor.inventory_service).to receive(:reserve_items).with(['item1'])
expect(processor.shipping_service).to receive(:create_shipment).with(order)
expect(processor.notification_service).to receive(:send_confirmation).with('[email protected]')
processor.fulfill_order(order)
end
end
end
🎉 Key Improvements:
Clear distinction between internal state changes and external collaborations
Tests that focus on the right contracts and responsibilities
Reduced test fragility from over-mocking internal implementation
Better test failure messages that point to actual problems
Monday Morning Action Items
Quick Wins (5-Minute Changes)
Review your recent tests and identify mixed assertion patterns
Separate state-change assertions from message-sending expectations
Remove mocks on the object under test for incoming command messages
Next Steps
Audit your test suite for command message classification
Create team guidelines for testing different message types
Refactor your most brittle tests using these patterns
Your Turn!
The Message Classification Challenge
How would you test these two methods differently?
class ShoppingCart
# Method 1: Changes cart state directly
def add_item(product, quantity)
items << CartItem.new(product: product, quantity: quantity)
self.total += (product.price * quantity)
self.updated_at = Time.current
end
# Method 2: Delegates to external service
def process_payment(payment_details)
payment_service.charge(
amount: total,
card: payment_details[:card],
customer_id: customer_id
)
end
end
💬 Discussion Prompts:
What would you assert for
add_itemvsprocess_payment?Why would mocking be wrong for one but right for the other?
How does this distinction help you write better tests?
🔧 Useful Resources:
Found this useful? Share it with a fellow developer who’s struggled with fragile tests! And don’t forget to subscribe for more practical testing insights.
Happy coding! 🚀
💡 Tips and Notes
Pro Tip: If you’re mocking the object under test, you’re probably testing an outgoing command message
Remember: Incoming commands change the object’s state; outgoing commands send messages to collaborators
