Is a common problem in a Rails app to have very slow test suites. There are a lot of test suites that need more than 10 or even 30 minutes to run!…
Is your app in this group?
If it is and you want to do something about it, I have a some tips for you!
One of the things that makes your tests run slow is the usage of the database on them, maybe you have heard about it… And for that reason some people recommend that if you don’t need to create a record, is better to just use the instance of the object without creating the record, for example, to calculate the total of an order…
# some people recomend this...
describe "Order" do
it "has a total" do
order = Order.new(
items: [
OrderItem.new(itemprice: 100, quantity: 3),
OrderItem.new(price: 200, quantity: 1)
]
)
expect(order.total).to eq 500
end
# instead of this...
it "has a total" do
order = Order.create
order.items.create(price: 100, quantity: 3)
order.items.create(price: 200, quantity: 1)
expect(order.total).to eq 500
end
end
And thats ok, I think…
But following the same example, lets focus now on the action when the user wants to add the same item again to the order. Where would you put that logic?… Let’s say that you want to put that behavior in your model, so let’s write a test for this…
describe "add item" do
example do
order = Order.new
order.add_item(Item.new(id: 1234, name: "I1", price: 110))
order_item = order.items.first
expect(order_item.item_id).to eq 1234
expect(order_item.name).to eq "I1"
expect(order_item.price).to eq 110
expect(order_item.quantity).to eq 1
expect(order_item.total).to eq 110
end
example "add the same item twice" do
order = Order.new
item = Item.new(id: 1234, name: "I1", price: 110)
order.add_item(item)
order.add_item(item)
order_item = order.items.first
expect(order.items.count).to eq 1
expect(order_item.quantity).to eq 2
expect(order_item.total).to eq 220
end
end
Hey, we did it =) … Hmmm…but where do we want to save the OrderItem? in the controller?… we can do it with something like…
class OrderItemsController < ApplicationController
def create
item = Item.find(params[:id])
order = Order.find(params[:order_id])
order.add_item(item)
order.save
redirect_to order_path(order)
end
end
Do you feel the need to test that the order is going to be saved in the right way?… Well I actually feel that need, we have modified the state of at least two objects and for me is not very clear if the data will be saved correctly… I would prefer to call save inside order.add_item and be able to test that…
But how do we test that, without using the database in our tests?
Before saying you how… I will start by saying you what kind of operations from ActiveRecord I can trust I am using fine…
In the last example I wasn’t sure how active record would save the data… but if instead we were doing something like…
OrderItem.create(
order_id: order.id,
item_id: item.id,
quantity: 1,
price: item.price
)
# or
OrderItem.update(order_item.id, quantity: 2)
Well.. I think that in these two examples is easier to trust that we are using ActiveRecord right, don’t you think?… So, If we are sure that ActiveRecord can handle this operation… What if instead of been tied to it, we take the OrderItem class as a dependency? and expect the usage of this two methods…
So let’s try to change our test to do it…
describe "add item" do
it "creates an order item record" do
order = Order.new
item = Item.new(id: 1234, name: "I1", price: 110)
expect(OrderItem).to receive(:create).with(
order_id: order.id,
item_id: item.id,
quantity: 1,
price: 110
)
order.add_item(item, OrderItem)
end
it "updates the record quantity when the item is added twice" do
item = Item.new(id: 1234, name: "I1", price: 110)
order_item = OrderItem.new(id: 12345, item_id: item.id, quantity: 1)
order = Order.new(items: [order_item])
expect(OrderItem).
to receive(:update).
with(order_item.id, quantity: 2)
order.add_item(item, OrderItem)
end
end
So now we are testing that we are creating or updating our records without actually creating or updating them, because now we are injecting a dependency that we trust that will do the job for us if we send the right message =)
If we do this change, now we can use our Order#add_item method on out controller without the need of calling save there…
class OrderItemsController < ApplicationController
def create
item = Item.find(params[:id])
order = Order.find(params[:order_id])
order.add_item(item, OrderItem)
redirect_to order_path(order)
end
end
Is this design better?… Hmmm I think it depends on who is deciding that…
Is this design simpler?… Hmmm I think it is, because now we are depending on a simpler behavior. But well that’s why I think =)
Will this design improve the performance of you tests?… Yes, for sure =)
…
Other thing that slows our tests, is to require the whole rails, to test our business logic… But the good news are that if we follow this approach, in a lot of cases you will not need it =)
For example… What I don’t like much about this last approach is that now I am not sure if the method add_order
should belongs to the order… Maybe we can have a function named add_item_to_order
that will receive the order
, the item
and the OrderItem
class…
Maybe something like this…
describe "add item to order" do
it "creates an order item record" do
order = Order.new
item = Item.new(id: 1234, name: "I1", price: 110)
expect(OrderItem).to receive(:create).with(
order_id: order.id,
item_id: item.id,
quantity: 1,
price: 110
)
add_item_to_order(order, item, OrderItem)
end
it "updates the record quantity when the item is added twice" do
item = Item.new(id: 1234, name: "I1", price: 110)
order_item = OrderItem.new(id: 12345, item_id: item.id, quantity: 1)
order = Order.new(items: [order_item])
expect(OrderItem).
to receive(:update).
with(order_item.id, quantity: 2)
add_item_to_order(order, item, OrderItem)
end
end
If we make this little change, and if you see the test in detail… Now we don’t need any special feature from rails, all we need is three “values”, the Item
, the Order
and the OrderItem
, and an object that will manage the storage of the OrderItems
… So now we can replace the active record objects with this…
class FakeItem
attr_reader :id, :name, :price
def initialize(attrs)
@id = attrs[:id]
@name = attrs[:name]
@price = attrs[:price]
end
end
class FakeOrder
attr_reader :items
def initialize(attrs)
@items = attrs[:items]
end
end
class FakeOrderItem
attr_reader :item_id, :order_id, :quantity, :price
def initialize(attrs)
@item_id = attrs[:item_id]
@order_id = attrs[:order_id]
@quantity = attrs[:quantity]
@price = attrs[:price]
end
end
class FakeOrderItemsStore
def self.create(attrs)
# no needed behavior for now
end
def self.update(id, attrs)
# no needed behavior for now
end
end
So we can change our test to use those “fake” objects…
describe "add item to order" do
it "creates an order item record" do
item = FakeItem.new(id: 1234, name: "I1", price: 110)
order = FakeOrder.new
expect(FakeOrderItemsStore).to receive(:create).with(
order_id: order.id,
item_id: item.id,
quantity: 1,
price: 110
)
add_item_to_order(order, item, FakeOrderItemsStore)
end
it "updates the record quantity when the item is added twice" do
item = FakeItem.new(id: 1234, name: "I1", price: 110)
order = FakeOrder.new(items: [
FakeOrderItem.new(id: 12345, item_id: item.id, quantity: 1)
])
expect(FakeOrderItemsStore).
to receive(:update).
with(order_item.id, quantity: 2)
add_item_to_order(order, item, FakeOrderItemsStore)
end
end
Jeje but now our test looks very ugly =( … let’s make some factory methods to make it look better…
describe "add item to order" do
def order_with(attrs)
FakeOrder.new(attrs)
end
def item_with(attrs)
FakeItem.new(attrs)
end
def order_item_with(attrs)
FakeOrderItem.new(attrs)
end
def order_items_store
FakeOrderItemsStore
end
it "creates an order item record" do
item = item_with(id: 1234, name: "I1", price: 110)
order = order_with(items: [])
expect(order_items_store).to receive(:create).with(
order_id: order.id,
item_id: item.id,
quantity: 1,
price: 110
)
add_item_to_order(order, item, order_items_store)
end
it "updates the record quantity when the item is added twice" do
item = item_with(id: 1234, name: "I1", price: 110)
order = order_with(items: [
order_item_with(id: 12345, item_id: item.id, quantity: 1)
])
expect(order_items_store).
to receive(:update).
with(order_item.id, quantity: 2)
add_item_to_order(order, item, order_items_store)
end
end
And where should we put this function?… well you can just create a module, maybe something like Orders
, and put it there… Actually you don’t have to worry to much about the module name, because as this function will receive all the things it needs as parameters, if you later decide to put it elsewhere you will be able to just change the code to that other place =)…
So now, your controller would look something like this…
class OrderItemsController < ApplicationController
def create
item = Item.find(params[:id])
order = Order.find(params[:order_id])
Orders.add_item(order, item, OrderItem)
redirect_to order_path(order)
end
end
Not so bad, don’t you think?
And that’s it!!… this is a not so hard way of putting the database or really any storage mechanism out of your business logic. And make your tests run fast!!
And here… some possible questions that you may have at this moment…
Well it depends on the code you are testing, but I can tell you that in the last two apps that I have applied this method one runs something like 1,000 tests in 2 seconds and the other runs 1,600 in more less 6 seconds.
Here the answer also depends on how you see it. Because this “fake code” is much, but really much much less code, than the ActiveRecord library’s code and the database’s code…
What I think is that this separation makes you more conscious and also makes your code more explicit about your dependencies… What are you really using from ActiveRecord? If you need a better/faster library or a better/faster database, how easy would be to replace it?
As we are not testing ActiveRecord (or the thing your are using to access your data…) you will need to be careful to detect if there are changes that break the behavior that you are expecting.
The good news are that…
And if you are not confident enough you can write some tests for your store to see if it has the behavior that you want, and with the rspec “shared examples” you can run them against your fake store and your real store.
For example…
RSpec.shared_examples "OrderItemsStore" do |parameter|
it "creates a record" do
store.create(attrs)
#...
end
# ...
end
RSpec.describe FakeOrderItemsStore do
attr_reader :store
before do
@store = FakeOrderItemsStore.new
end
it_behaves_like "Order Items Store"
end
RSpec.describe OrderItem do
def store
OrderItem
end
it_behaves_like "Order Items Store"
end
RSpec.describe YourNewOrderItemStore do
def store
YourNewOrderItemStore
end
it_behaves_like "Order Items Store"
end
This tests will need to interact with the actual storage mechanism but you will have much less tests here, because you are not going to re-implement ActiveRecord, you will only use a small part of it.
Well, here we are testing that we are sending the right message to the “store”… Is similar to what Sandy Metz calls “testing outgoing messages with side effects” in her talk The Magic Tricks of Testing.
But one cool thing, is that in real apps, this addorderto_item functionality will need much behavior soon… maybe send an email, track some analytics data, send a notification… who knows?… But now you have a place were you can put and/or test that code.
Hmmm, that really is a question to you… what is good code?, what is bad code?, do you prefer to have slow tests?, do you prefer not to write tests?, do prefer to look for faster computers, or parallelize your code?
Well, actually is kind of a procedure, but also is kind of a function, and we are passing objects… and nothing is preventing you from creating new objects inside that function/procedure when you think is needed.
Also about procedures… Avdi Grimm has a good article about them: Enough With the Service Objects Already.
You can start in a similar way… In the next change you will make to your app, pick a little functionality and put it behind a module and a function =)
…
And that’s all for now. I hope you can take something from this message =)
Here I try to share knowledge and fixes to common problems and struggles for ruby on rails developers, like How to fetch the latest-N-of-each record or How to test that an specific mail was sent or a Capybara cheatsheet. You can see more examples on Most recent posts or all posts.