Before we move on from the shopping cart to payment and checkout (coming up next in Chapter 2, Take the Money), I want to show some of the tests that I wrote to drive the AddsToCart object because they show a technique that we’re going to use as we work with our business logic and third-party services. Specifically, I used RSpec’s verifying doubles when testing AddsToCart to isolate the test from the database and allow us to verify the workflow logic without dependency on any other part of the code.
Note that in order to run the tests, you need to have created the PostgreSQL database (rails db:create:all and rails db:migrate), and that PostgreSQL must be running.
Now, the workflow test:
| require "rails_helper" |
| |
| describe AddsToCart do |
| |
| let(:user) { instance_double(User) } |
| let(:performance) { instance_double(Performance) } |
| let(:ticket_1) { instance_spy(Ticket, status: "unsold") } |
| let(:ticket_2) { instance_spy(Ticket, status: "unsold") } |
| |
| describe "happy path adding tickets" do |
| it "adds a ticket to a cart" do |
| expect(performance).to receive(:unsold_tickets) |
| .with(1).and_return([ticket_1]) |
| action = AddsToCart.new(user: user, performance: performance, count: 1) |
| action.run |
| expect(action.success).to be_truthy |
| expect(ticket_1).to have_received(:place_in_cart_for).with(user) |
| expect(ticket_2).not_to have_received(:place_in_cart_for) |
| end |
| end |
| |
| describe "if there are no tickets, the action fails" do |
| it "does not add a ticket to the cart" do |
| expect(performance).to receive(:unsold_tickets).with(1).and_return([]) |
| action = AddsToCart.new(user: user, performance: performance, count: 1) |
| action.run |
| expect(action.success).to be_falsy |
| expect(ticket_1).not_to have_received(:place_in_cart_for) |
| expect(ticket_2).not_to have_received(:place_in_cart_for) |
| end |
| end |
| |
| end |
There are two tests here that share a common setup. The first one tests the success case of adding a ticket to a cart; the second tries to simulate a specific failure case.
The common setup is done by the series of let statements at the beginning of the test file. Each of them sets up an RSpec validating double. Generically, a test double is a “fake” object that stands in for a real object that interacts with the code being tested (the name is analogous to “stunt double”). We say the object is fake because instead of processing or logic, the double has canned values that it supplies in response to messages. You’ll sometimes see these called “mock objects” or “stubs”; it’s the same idea, but “test double” is the more generic term.
For what it’s worth, when initially coding I did write these tests before the code, but not using doubles. I went back and refactored the tests to use doubles. There are several reasons why a test double might be preferable:
To simulate an object that might be expensive to use or might have bad side effects because it’s created by a third-party service or because using it has real-world implications
As a shortcut to simulate failure states that are hard to create otherwise
To abstract the interface of an object and potentially reduce its dependence on the details of other objects it interacts with
In these two tests, we’re doing a little bit of all three. We’re using doubles to create objects that otherwise would have to be saved to the database. As much as we Rails developers like to pretend otherwise, the database is a third-party dependency, and it can be slow to create large numbers of objects there.
The second spec is simulating a potential failure case, namely that the performance doesn’t have enough tickets to fulfill the order. We do so by explicitly saying that the performance should receive :unsold_tickets and return an empty list. The empty list is what unsold_tickets, itself tested separately, would return if there are no tickets to sell. By directly setting a double on the performance method, we sidestep the need to create an event or ticket objects that would produce the failure mode. Although this case isn’t too hard to set up with real objects, other failure cases—which might include networking or database failures—can be hard to automate without using doubles.
And in both cases, we’re using the doubles to minimize the amount of interaction the AddsToCart workflow has with other objects. When every interaction potentially requires more test double support, you have an incentive to keep individual objects as isolated as possible.
The first two lines of setup create stand-ins for a User and a Performance using RSpec’s instance_double method. By using instance_double, we allow RSpec to validate the methods received from our double against the specified class. If our double is asked to respond to a message that the class does not define, RSpec raises an error.
For example, the first double we create is a fake user: let(:user) { instance_double(User) }. We can specify that the fake user has a particular email address using RSpec and the syntax allow(user).to respond_to(:email).and_return("will@shakespeare.com"). Then user.email, when called, will return will@shakespeare.com. However, we can’t specify that the fake user has, say, a favorite food. Even if we try to use the syntax allow(user).to receive(:favorite_food).and_return("cheeseburger"), RSpec will just return the error: the User class does not implement the instance method: favorite_food.
This verification is really useful because it prevents a common problem when using test doubles: the possibility of changes in the object’s method names drifting away from the method names used by the double in the test.
For example, say we have a user method called name, and a test that has a double referencing that method: allow(:user).to receive(:name). If we change the name of the underlying method to full_name, then without RSpec’s validating double, the test still passes because the test only knows about the double. But with the validating double, we’ll get a message saying the User class does not implement the instance method: name and we’ll know to change the method under test.
Our last two lines of setup create two tickets using the instance_spy method with a key/value argument status: "unsold", which seeds the double to know about the status method and to have a response ready if called. The instance_spy method creates a validating double just like instance_method; however, it adds the detail that any valid method that is not specified in the double returns nil, rather than throwing an error. Methods that are not defined in the class at all still throw errors. We’re doing that here to allow the tickets to save themselves to the database or whatever else they do without this test needing to care about the details.
The test for success adds one additional expectation, which is that the performance double will receive the message unsold_tickets(1) and return a list of one ticket. Using expect instead of allow when setting the exception means that the test will fail if the unsold_tickets message is not received. We then create an AddsToCart workflow, passing it the user double and the performance double.
We run the workflow, and then have three more expectations: we expect the result of the workflow to be true, we expect that the ticket returned as unsold is added into the cart by the method place_in_cart_for, and we expect that the other ticket did not. As far as the AddsToCart workflow goes, this is a complete description of its behavior when run. Additional unit tests (which are in the code base, but that we won’t show in the book) verify that the method place_in_cart_for saves the test with the values that we’d expect: changing the status and associating it to a user.
The failure test is nearly identical, but sets up the case where there are no tickets available to be added to the cart by having the performance respond to unsold_tickets by returning an empty list. In this case, we expect the result of the workflow to be false, and we expect that neither ticket will receive the place_in_cart_for message.