Here at Stitch Fix, we have many different apps and services. As our infrastructure grows, so does the need to create and define more “micro-services” to centralize and isolate important shared behavior and data. Testing is a very important part of our development process. As we started creating more and more services, we realized that we had to change the way we think about integration tests when services are involved. Here’s a typical workflow that you may be familiar with:
- Write a unit test for an expected feature.
- Implement the expected feature.
- Update the unit test to stub any external references to ensure your unit test only verifies the unit of work we are testing. (Follow Red/Green/Refactor)
- Supplement your test suite with acceptance and integration level specs to ensure the new feature works in it’s entirety.
What happens if you stub a service call in your unit test but the integration test tries to hit the service endpoint? Creating a services oriented architecture will continually increase the complexity and dependencies between application endpoints. For example, what if our new service is dependent on another service, which is dependent on another service and so on … How do you keep a consistent state to do testing locally, and on your continuous integration platform? Do you want to set-up Docker on all your apps and services just to run one test suite? Does adding a new service warrant all of the extra complexity that this implies?
I think having an integrated testing environment with Docker can be very helpful to maintain a few very important use cases; particularly if you are moving to a more distributed architecture. HOWEVER, I think there is a better way to handle the majority of testing needs, without requiring your developers to install and learn a new platform technology. How? By using consumer driven contracts (CDC) to shore up the endpoints of your programing interface.
Consumer driven contracts allow the consumer of a service to define an expected behavior from a service. The service can then “link” to that specification and validate that it does indeed meet the expectations. This allows you to have a high degree of confidence in your test suite, while decoupling the implementation details and keeping things simple. No need to set up a special environment where each service can spin up a version of itself and talk to each other.
Our website and mobile app allow our clients to schedule a new ‘fix’ (or a shipment). Suppose we want to write a service that is capable of scheduling a reservation for one of our clients. Here’s an example of how we could define this contract using the pact gem:
In the API client, declare that you have a contract with the “scheduling service”:
spec/service_providers/pact_helper.rb
require 'spec_helper'
require 'pact/consumer/rspec'
Pact.service_consumer "A new app that wants to Schedule!" do
has_pact_with "Scheduling Service" do
mock_service :scheduling_service do
port 1234
end
end
end
This creates a mock service that will be used in our specs. Next, let’s define how our new app expects to interact with our “scheduling service”:
spec/service_providers/scheduling_service_client_spec.rb
require_relative 'pact_helper' # the file shown above
require 'spec_helper'
describe StitchFix::SchedulingClient::Client, :pact => true do
subject {
described_class.new(
endpoint: 'http://localhost:1234',
api_key: '3e61-0eda-46ef-860')
}
let(:fix_request) { ... }
let(:client_id) { ... }
let(:request_headers) { ... }
describe 'schedule_fix_request' do
context 'good request' do # happy path scenario
let(:request_body) {
Hashie::Mash.new(fix_request: fix_request, client_id: client_id)
}
let(:expected_okay) {
Hashie::Mash.new(reservation: Hashie::Mash.new(status: 'ok'))
}
before do
# Define the expected behavior of the service
scheduling_service.given('a fix request and a client id').
upon_receiving('a request to schedule a fix').
with(method: :post,
path: schedule_a_fix_request_path,
headers: request_headers,
body: request_body).
will_respond_with(
status: 201,
body: { reservation: {status: 'ok'} }
)
end
it 'returns ok if everything is successful' do
expect(
subject.schedule_fix_request(fix_request, client_id)
).to eq(expected_okay)
end
end
end
end
Running rspec on this file verifies that the client method sends the correct request and returns the expected result. If the tests pass, a pact agreement for the service is generated. The pact agreement is simply a JSON representation on the spec defined above. This file will be regenerated each time your test suite runs. It will be the shared piece of information between the client and the service.
Here is another example that tests for an error condition:
context 'missing fix request' do
let(:missing_fix_request) { {} }
let(:request_body_missing_fix_request) {
Hashie::Mash.new(fix_request: missing_fix_request, client_id: client_id)
}
before do
scheduling_service.given('a request with a missing fix request').
upon_receiving('a request to schedule a fix').
with(method: :post,
path: schedule_a_fix_request_path,
headers: request_headers,
body: request_body_missing_fix_request).
will_respond_with(
status: 422,
body: {
errors: [{
code: 'param_missing',
message: 'param is missing or the value is empty: fix_request'
}]
}
)
end
it 'raises an error if the fix request is missing' do
expect {
subject.schedule_fix_request(
missing_fix_request, client_id)
}.to raise_error /param is missing or the value is empty: fix_request/
end
end
end
That’s all we need to do for the consumer application.
Now, since we’ve already generated the pact agreement, we can add validation code in the scheduling service.
First, declare the pact agreement:
spec/service_consumers/pact_helper.rb
require 'pact/provider/rspec'
require "./spec/service_consumers/provider_states_for_new_app"
Pact.service_provider "Scheduling Service" do
honours_pact_with 'A new app that wants to Schedule!' do
pact_uri Pact::Provider::PactURI.new("path to json file generated above")
end
end
Next, provide an implementation file for the pact agreement:
spec/service_consumers/provider_states_for_new_app.rb
require 'spec_helper'
Pact.provider_states_for "A new app" do
provider_state "a fix request and a client id" do
set_up do
# do any test setup you may need:
# for example: client = FactoryGirl.create(:active_client)
# or
no_op
end
end
provider_state "a request with a missing fix request" do
set_up do
# this will invoke an api request,
# let's make sure we have some test data to work with
client = FactoryGirl.create(:active_client)
allow_any_instance_of(Customer).to receive(:client).and_return(client)
end
end
end
Notice how the provider_state text string matches the ‘given’ string in the client spec.
Executing ‘rake pact:verify
’ runs the tests specified in the client pact file and verifies that it responds according to
the defined behavior. If the service makes a breaking change to the specification, it will no longer honor the pact and
the tests we just wrote will fail, without the need to write an integration test!
As you can see, the pact gem makes it pretty easy to shore up the gaps between the endpoints in your services oriented architecture. The only tricky thing we left out is how to manage the shared JSON specification file between your consumer repository and your service repository. But that is out of scope for this article. I would suggest you look into using pact brokers or tapping into your continuous integration tool to pass the shared resource between builds.