DEV Community

Andrew Kozin for Evil Martians

Posted on • Originally published at evilmartians.com

A Fixture-Based Approach to Interface Testing in Rails

The Problem

In the past few months, my team has been working on a big project written with Ruby on Rails. The key component of our application is integration to many external APIs like different eBay services, postal services, logistics operators, translation services, etc. All the interfaces we use are complex and rich. Most of them are refined continuously: new versions are released from time to time, while the legacy ones stop being supported. Generally, for projects this complex, you should expect neither full nor 100% consistent documentation coverage.

The whole picture became even more complex when we turned from external services to "internal" ones that lay between front-end and back-end parts of the application. Some time ago we decided to switch from RESTful API to GraphQL here, and our life as developers became happier. But there's no honey without bees. The bee-ness of GraphQL interface is the back side of its flexibility. And the more flexible the API became, the more edge cases we had to take into account and cover by tests.

That's why on the back-end we rely on our own tests, trying to cover as many corner cases as we can. Sometimes the specs are the only evidence of how the program was intended to work. Even the author of a feature forgets details over time. But it’s a bad sign when a spec has to be read by colleagues, whether they are code reviewers or new members of the team. That's why the readability of tests matters and is worth the effort.

But what does it mean for a test to be readable? I believe that it should not only cover edge cases properly but tell a story about our expectations. It must do this step-by-step, without overloading a reader with details. Ideally, it should show the big picture of what we expect, and this declaration must be separate from how we model the expectation.

Skipping ahead, I would like my test to look something like this:

RSpec.describe ProductTranslator, ".call" do
  subject { described_class.call(listing) }

  before do
    stub_fixture "#{__dir__}/#{edge_case}/stubs.yml"
    seed_fixture "#{__dir__}/#{edge_case}/database.yml", listing_id: 42
  end

  let(:listing) { Listing.find(42) }
  let(:target)  { load_fixture "#{__dir__}/output.yml" }

  it "updates the listing with translated specifics" do
    expect { subject }.to change { listing.reload.specifics }.to target
  end
end

In the rest of the post I'll try to show our fixture-based approach to making tests understandable, and small tools we use for this goal. Because we use the Rails-based application, I refer to the specific tools from its ecosystem (RSpec, FactoryBot, and the Database Cleaner). But the whole idea of this post is framework-agnostic: use fixtures to prepare as much context for the specification as you can.

Web API as a Test Subject

Before going further, let’s look at the APIs as a specific subject of the test.

From the consumer's point of view, all public web interfaces are alike. Following its business logic, the application prepares a request and sends it to the server. The server responds with data that must be processed it one way or another, depending on the content of the response. Most interfaces use text-based formats (JSON, XML, GraphQL) for data serialization.

Here we should specify two parts of the consumer's logic:

  • the correctness of request preparation depending on the internal state of the app,
  • the correctness of response processing which depends on both the response and the internal state of the application.

To be more specific, we use three different specs:

  • unit tests for request builders,
  • unit tests for response handlers,
  • point-to-point test for the whole service doing the request.

Sometimes it makes sense to specify under-documented APIs as well. The goal is not to test a remote server, but ensure the correctness of our own expectations about its behavior. For simplicity, I keep this task aside. Those who interested in the corresponding techniques can check Blood Contracts by my colleague Sergey Dolganov.

When testing our own application, we must stub the remote interfaces because they are out of our control. This is an important lesson I’ve learned over the years: use real responses obtained “in vivo” for stubbing! Learning this lesson was not easy for me. Many times when I used examples "in vitro" from docs it led to hours of debugging. The rule of thumb is “stub APIs based on real data.”

To summarize, the context of our tests include:

  • DB seeds and stubs to provide the internal state of the application,
  • stubs of our client to the external API,
  • real examples of requests/responses (possibly mutated for various edge cases).

Fixtures Take the Stage

First, let's look at an example. This is a specification of a class that translates nested data like { name: "Color", value: "Red" } using the GoogleTranslateDiff client to Google Translate API. While we hide HTTPS requests and responses behind the client interface, this wrapper is thin enough to accept plain text along with locales, and return a plain text.

# spec/services/product_translator/_spec.rb

RSpec.describe ProductTranslator, ".call" do
  subject { described_class.call(listing) }

  before do
    # Stub the client to remote API
    allow(GoogleTranslateDiff)
      .to receive_message_chain(:translate)
      .with do |text, from:, to:|
        case [text, from, to]
        when %w[Color en ru] then "Цвет"
        when %w[Black en ru] then "Чёрный"
        when %w[Brand en ru] then "Марка"
        when ["<span class='notranslate'>Apple</span>", "en", "ru"]
          "<span class='notranslate'>Apple</span>"
        else raise
        end
      end
  end

  # Seed the necessary objects in the database
  let(:listing) { FactoryBot.create :listing, source: product, locale: target }

  let(:product) do
    FactoryBot.create :product, :ready, locale: :en, specifics: input
  end

  # Prepare data structures for input/output
  let(:input) do
    [{ name: "Color", value: "Black" }, { name: "Brand", value: "##Apple##" }]
  end

  let(:output) do
    [{ name: "Цвет", value: "Чёрный" }, { name: "Марка", value: "Apple" }]
  end

  context "when target locale differs from the product's one" do
    let(:target) { "ru" }

    it "updates the listing with translated specifics" do
      expect { subject }.to change { listing.reload.specifics }.to output
    end
  end

  context "when target locale is the same as the product's one" do
    let(:target) { "en" }

    it "updates the listing with the original specifics from the product" do
      expect(GoogleTranslateDiff).not_to receive(:new)
      expect { subject }.not_to change { listng.reload.attributes }
    end
  end
end

As you can see, even a simple test can become difficult to read when there are too many details.

In complex cases (when you should vary both the internal state and responses to cover all corner cases) a whole specification turns into spaghetti. But there's good news. As I mentioned, both the request and response are just text-formatted in some way (JSON, XML, GraphQL, etc.). As a rule, our clients deserialize those data into JSON-compatible basic Ruby objects, not application-specific classes.

That's why we can easily extract them into fixtures like this:

# spec/services/product_translator/output.yml
---
- name:  Цвет
  value: Чёрный
- name:  Марка
  value: Apple

Now we can simplify our specification a bit:

let(:output) { YAML.load_file "#{__dir__}/output.yml" }

The specification became better, but not much better. We still have stubs and seeds in it. What's more important is that after hiding some structures in fixtures, the model expectations became distributed between the fixture and the test, and the observability was lost. For example, when reading the specification, it’s unclear why we stub the client with 'Color', 'Black', 'Brand', etc. What role do they play?

Wouldn't be better if we could move all the specific parts, including stubbing and seeds, into fixture layer, and make the specification just a clue? If we could move all the details of method stubbing, DB seed, and data serialization into fixtures?

But we can! Meet the Fixturama, a kind of “fixture on steroids.” The gem was extracted from our project and has been heavily used in production for several months. You can look at the details of usage in a README. Here I’ll show how its usage can improve our specification.

In the next section, I'll show you several simple techniques supported by the gem.

Fixtures on Steroids

To add helpers to your suite just add the dependency:

require "fixturama/rspec"

Stubbing

Now we can start with stubbing. In a YAML file you can define a set of opinionated keys:

# spec/services/product_translator/stubs.yml
---
- class: GoogleTranslateDiff
  chain:
    - new
    - translate
  arguments:
    - Color
    - :from: en
      :to: ru
  actions:
    - return: Цвет

- class: GoogleTranslateDiff
  chain:
    - new
    - translate
  arguments:
    - Brand
    - :from: en
      :to: ru
  actions:
    - return: Марка

- class: GoogleTranslateDiff
  chain:
    - new
    - translate
  arguments:
    - Black
    - :from: en
      :to: ru
  actions:
    - return: Чёрный

- class: GoogleTranslateDiff
  chain:
    - new
    - translate
  arguments:
    - "<span class='notranslate'>Apple</span>"
    - :from: en
      :to: ru
  actions:
    - return: "<span class='notranslate'>Apple</span>"

- class: GoogleTranslateDiff
  chain:
    - new
    - translate
  actions:
    - raise: StandardError

While the fixture got quite verbose (because we define separate action for every variance of arguments), the specification itself is now simple:

before { stub_fixture "#{__dir__}/stubs.yml" }

In some cases, you will collect all the stubs in one fixture, or you can split them between different specs (for various edge cases).

You can customize more options here to set different returns for consecutive calls of the method, etc. See more examples on Github.

Seeding

After moving stubs away, we can do the same for database objects. Here the fixturama provides a thin wrapper around the FactoryBot. Another opinionated syntax:

# ./database.yml
---
- type: product
  traits:
    - ready
  params:
    id: 1
    locale: en
    specifics:
      - name:  Color
        value: Black
      - name:  Brand
        value: '##Apple##'

- type: listing
  params:
    id: <%= listing_id %>
    source_id: 1 # bound to the product above
    locale: <%= target %>

In a specification you run the seed as:

before { seed_fixture "database.yml", listing_id: 42, target: "ru" }

let(:listing) { Listing.find(42) }

Now we’ve removed all the details from the specification. The only thing we need to tell a reader is: “there's a listing with the same id as we used in a fixture.”

Without fixtures the database preparation would look like this:

let(:listing) { create :listing, source: product, locale: "ru" }
let(:product) do
  create :product, :ready, locale: "en", specifics: [
    { "name" => "Color", "value" => "Black" },
    { "name" => "Brand", "value" => "##Apple##" },
  ]
end

It may seem shorter than a fixture (unless you need huge jsonb structures inside). But remember the goal: I wanted to move all the details out of specs. If I moved stubs and results only and left database objects in the specification, I would end up with settings split between the test and its fixtures. This approach would make understanding what's going on much harder for a reader.

With the “fixture-based” approach we don't even need to look at the specification. We can just compare the database.yml and seeds.yml with the output.yml they provide. The specification by itself just serves a clue between those fixtures.

Notice that under the hood we use the ERB binding supported by all the fixturama helpers. You can use it in seeds, stubs, and loads in exactly the same way.

Traps and Workarounds

Now I must stop and tell you about a trap. To refer different objects to each other inside a seed fixture we use concrete ids. Let's look at a more verbose example from another specification:

---
- type: product
  traits:
    - ready
  params:
    id: 1
    locale: en

- type: listing
  traits:
    - published
  params:
    id: <%= listing_id %>
    source_id: 1
    locale: <%= target %>

Here I just need a published listing of the ready product described in English. To bind them together I hardcoded the id of the product, but this could create a problem with flaky, non-deterministic tests.

This problem occurs when you combine explicit ids with those generated by default by the database. In isolation, every spec can work fine, but running them in random order can lead to conflict. Of course, you could use the truncation strategy of the database cleaner, but this leads to a huge loss of performance.

Now we have a much better solution, both clean and elegant. The trick is to add the offset o DB sequences of ids so that they start iteration from a big number (say, 10'000'000). Now you can safely use smaller ids in fixtures; the database would never generate the same record identifiers by default.

In the fixturama you can use a special setting:

# ./rails_helper.rb
RSpec.configure do |config|
  config.before(:suite) { Fixturama.start_ids_from(10_000_000) }
end

Serialization

All we have left is slightly improve the serialization. Remember the original way of extracting fixtures:

let(:output) { YAML.load_file "#{__dir__}/output.yml" }

With the fixturama you could do the same using a loader:

let(:output) { load_fixture "#{__dir__}/output.yml" }

This is not a big improvement, but you don't need to specify a format explicitly. Both the YAML and JSON-formatted files are serialized automagically; otherwise, the content is loaded as a plain text.

You can also bind values via ERB now; there are many cases when this option is useful.

Test Organization

As you might have noticed, in all the examples above I used the __dir__ world to refer to the current folder, instead of placing fixtures into the special folder like spec/fixtures. Keeping a fixture close to the specification makes it much easier to understand and modify.

Typically, I create a folder for every specification and organize files like this:

specs
  services
    translation
      _spec.rb
      database.yml
      stubs.yml
      output.yml

For many edge cases you can go deeper:

specs
  services
    translation
      _spec.rb
      valid_input
        database.yml
        stubs.yml
        output.yml
      invalid_input
        database.yml
        stubs.yml
        output.yml

Now all you need to cover a new edge case is just copy-paste a subfolder and modify its content.

The Final Example

Let's look at the final example of how a specification looks after all these modifications:

require "fixturama/rspec"

RSpec.describe ProductTranslator, ".call" do
  subject { described_class.call(listing) }

  before do
    stub_fixture "#{__dir__}/#{edge_case}/stubs.yml"
    seed_fixture "#{__dir__}/#{edge_case}/database.yml", listing_id: 42
  end

  let(:listing) { Listing.find(42) }
  let(:output)  { load_fixture "#{__dir__}/#{edge_case}/output.yml" }

  context "when target locale differs from the product's one" do
    let(:edge_case) { "different_locales" }

    it "updates the listing with translated specifics" do
      expect { subject }.to change { listing.reload.specifics }.to output
    end
  end

  context "when target locale is the same as the product's one" do
    let(:edge_case) { "same_locales" }

    it "updates the listing with the original specifics from the product" do
      expect(GoogleTranslateDiff).not_to receive(:new)
      expect { subject }.not_to change { listng.reload.attributes }
    end
  end
end

The last change I would suggest here is to extract expectations into shared examples:

require "fixturama/rspec"

RSpec.describe ProductTranslator, ".call" do
  subject { described_class.call(listing) }

  # DEFINITIONS

  before do
    stub_fixture "#{__dir__}/#{edge_case}/stubs.yml"
    seed_fixture "#{__dir__}/#{edge_case}/database.yml", listing_id: 42
  end

  let(:listing) { Listing.find(42) }
  let(:output)  { load_fixture "#{__dir__}/#{edge_case}/output.yml" }

  # EXAMPLES

  shared_examples "translating specifics" do
    it "updates the listing with translated specifics" do
      expect { subject }.to change { listing.reload.specifics }.to output
    end
  end

  shared_examples "skipping translation" do
    it "doesn't call the remote API" do
      expect(GoogleTranslateDiff).not_to receive(:new)
      subject
    end

    it "updates the listing with the original specifics from the product" do
      expect { subject }.not_to change { listng.reload.attributes }
    end
  end

  # CONTEXTS

  context "when target locale differs from the product's one" do
    let(:edge_case) { "different_locales" }
    include_examples "translating specifics"
  end

  context "when target locale is the same as the product's one" do
    let(:edge_case) { "same_locales" }
    include_examples "skipping translation"
  end
end

All the details of how specs are prepared, and how examples are modeled, are isolated in fixtures and shared examples accordingly.

With this change we can easily cover new edge cases with the following small steps:

  • add the new folder with corresponding fixtures,
  • add new context to the spec, making sure to select a folder and expected behavior.

Now the whole test will remain understandable and traceable even with dozens of corner cases covered.

Further Improvements

There are more improvements and optimizations available. I’ll stop here and just refer to the gem test-prof by Vladimir Dementyev (aka palkan) where you can find many ideas.

For example, if you need the same database objects for all edge cases, you can make the preparation a bit faster by changing before to before_all:

before_all { stub_fixture "#{__dir__}/database.yml" }

This time you will have the time-consuming task of creating the database objects only once. All in all, I highly recommend looking at the gem and using it in your specs, either fixture-based or not.

Some Restrictions

I started this post with a discussion of API-specific problems but later explored how the fixturama could be a good solution for many specifications, not related to public interfaces. Sometimes this will be true; we use the gem for testing data mappers, some service objects, and policy objects not related to any external API. Unfortunately, YAML or JSON-serialized structures cannot cover all the necessary cases. Its suitability backs on the typical formats of the API requests and responses (text, JSON, YAML, GraphQL).

Another limitation arises from a possible irregularity of tests. When you have to cover many corner cases with the same structure (seed, stub, prepare the input and expected output), this approach works well. When you want to test different methods with heterogeneous preparation steps, it becomes less useful.

The third restriction has to do with the verbosity of the fixtures. It works well when you extract big structures from the test, and is suitable when you can “program behavior” by comparing complex input.yml to output.yml, without having to care about the nuts and bolts of the spec. But if your structures are simple (like a short string on input compared to boolean on output), it converts to an overkill. That's why in our real test suite we combine fixture-based specs with more traditional ones.

All that said, I still recommend you try to simplify specifications with the Fixturama and make your interaction with API more safe, predictable, and understandable for you and your team.

I’d love for you to give it a try, and leave your feedback on what can be done better, and how your test cases could be simplified further with additional settings.

Top comments (6)

Collapse
 
martinstreicher profile image
Martin Streicher

I assume the call to stub_fixture loads all the stubs and thus each stub has to be unique?

Collapse
 
nepalez profile image
Andrew Kozin

Yes, it loads all the stubs defined in the fixture.

If you need more customization for different edge cases, then you can split your stubs between different fixture files.

For example, you can define a default behavior (stubbing a method with no arguments) in one fixture. Then in another fixture define more specific behavior for some arguments:

# Default behavior for Translate.call(text, from: source, to: target)
---
- class: Translate
  chain: call
  actions:
    - raise: TranslationNotFoundError
# Specific definition for Translate.call("Color", from: "en", to: "de")
---
- class: Translate
  chain: call
  arguments:
    - Color
    - :from: en
      :to:   de
  actions:
    - return: Farbe

It doesn't matter, whether you define stubs in one fixture, or split them between several files. The only thing that matter is the loading order -- in case you stub the same method with the same set of arguments twice.

Collapse
 
martinstreicher profile image
Martin Streicher

Thanks. And since the stubbing happens in a before (I assume :each) the stubs are reset each time and persist only in the current context.

Thread Thread
 
nepalez profile image
Andrew Kozin

Exactly!

The only reason to split fixtures is arranging them in a more readable manner. Personally, I prefer just to copy-paste the whole fixture for every single case and see no reason in DRY-ing them.

But I can imagine cases when splitting worths it.

Thread Thread
 
martinstreicher profile image
Martin Streicher

Thanks for the reply. I suppose you can use the alias feature of YAML to share sections of the configuration, at least within the same file.

Collapse
 
vfonic profile image
Viktor

This is amazing! :)

I was sceptical at first, but you covered all the pros and cons and answered all the questions I had in my head. It's clear that this is something you built after a lot of work with factories/fixtures and hitting all the walls there. Thanks!

I'll give it a try when the opportunity comes. :)