Lessons from Rails: Focus on State in Tests

Lessons from Rails: Focus on State in Tests
import { lessons } from 'rails'; // part 3

Bringing techniques used in Rails to Node

late
Better late than never. I'm happy, if not surprised, to be learning so much from Rails in 2018.

This is the third post in a series about techniques I found in Rails that were so good, they needed to be ported to my Node projects.

Technique 3 - In tests, focus on state

This is another technique gleaned from RSpec, a BDD test framework commonly used as an alternative to Rails' default testing framework, Minitest. RSpec's let expression allows you to abstract setup code in a convenient way. I'll try to illustrate by way of example, but first...

A word of caution: this technique may not agree with your testing sensibilities

In my experience, smart people often disagree on test readability and structure. If you like your tests to be extremely readable, even at the expense of repeating yourself, this technique is probably not for you. If, on the other hand, you don't mind a bit of abstraction in your tests to make things more concise, keep reading. For me, readability is important, but I don't mind that my test files feel more like programs and less like stories. I like the balance that RSpec strikes and let is a big part of that.

A quick explanation of RSpec's let

RSpec's let is, simply put, another way of binding the result of an expression to a variable. You might see let expressions like this:

let(:avg_chicken_weight) { 1.2 }
let(:num_chickens) { 50 }
let(:total_chicken_weight) { avg_chicken_weight * num_chickens }

You can see that let expressions can reference other let expressions.

let expressions often serve similar purposes to before blocks; they contain setup code necessary to for the initial conditions of your tests. Some of the advantages of let are explained nicely in this stackoverflow answer.

simple

The real value of let

But, in my opinion, the real value of let is that allows you to focus on the state that describes test conditions, rather than steps required to create that state.

For example, let's say we're setting up an integration test for some classes in a blog system. Specifically, we're going to test a user's ability to edit someone else's blog post when the user is a member of different groups. Users that are a member of the editors group should be able to edit other users' posts. Users that are a member of the default group should not.

We need to initialize a few different entities for the test. Using RSpec and before block semantics, we might do it like so.

describe 'User permission to edit' do
  subject { @post.edit(@user) }

  before do
    @post = create :post
    group = create :group, is_admin: false
    @user = create :user, group: group
  end

  it 'cannot edit another user's post' do
    expect{ subject }.to raise_error
  end

  context 'when user is a member of an admin group' do
    before do
      group = create :group, is_admin: true
      @user = create :user, group: group
    end

    it 'can edit another user's post' do
      expect{ subject }.not_to raise_error
    end
  end
end

Now let's refactor using let. Notice that the nested context is much cleaner and the setup code has been reduced to a single expression that communicates purpose.

describe 'User permission to edit' do
  subject { post.edit(user) }

  let(:post)   { create :post }
  let(:is_group_admin) { false }
  let(:group)  { create :group, is_admin: is_group_admin }
  let(:user) { create :user, group: group }

  it 'cannot edit another user's post' do
    expect{ subject }.to raise_error
  end

  context 'when user is a member of an admin group' do
    let(:is_group_admin) { true }

    it 'can edit another user's post' do
      expect{ subject }.not_to raise_error
    end
  end
end

With the before blocks approach, we were forced to rehearse the steps necessary to initialize this new test. But this isn't really important when trying to understand the point of the test. The point is the state: the user is now part of an admin group.

Can we do it in Javascript?

Sorta, yeah!

describe('User permission to edit', () => {
  let s;
  beforeEach(() => {
    s = new StateDefinition({
      post: create('post'),
      isGroupAdmin: false,
      group: (state) => create('group', { isAdmin: state.isGroupAdmin }),
      user: (state) => create('user', { group: state.group }),
    });
  });

  const subject = () => { s.post.edit(s.user) }

  it('cannot edit another user's post', () => {
    expect(subject).toThrow();
  });

  describe('when user is a member of an admin group', () => {
    beforeEach(() => {
      s.define({ isGroupAdmin: true });
    });

    it('can edit another user's post', () => {
      expect(subject).not.toThrow();
    });
  });
});

Clearly, we snuck in an new construct, StateDefinition. This class was designed by a colleague and I as we were attempting to reduce the cognitive overhead required to switch back and forth between Rails RSpec tests and our frontend React tests.

StateDefinition

The StateDefinition class is used in the Javascript example as a stand in for the let expressions from the Ruby example. While not as terse, it has some of the same useful properties.

  • Properties defined can reference other properties in the state definition.
  • Properties can be defined as functions, but are accessed as properties.
  • Properties are evaluated as-needed. This means that if you redefine a state property like we do with isGroupAdmin, its value will bubble up to other properties when they are referenced in tests.
  • Property values are memoized. Once evaluated, functions that define properties will not be called again. This is useful when state definitions involve stateful operations like creating a database entry or rendering a React component with Enzyme.

The StateDefinition API is pretty simple. You can pass in an object of property definitions at instantiation - definitions that can either be statements or functions. Later, you can update those definitions with the .define() method.

All properties are accessed as properties, even if they are defined as functions. This allows you to not worry about how properties were defined and helps with readability of the tests.

StateDefinition is available as a npm package. Give it a try!

Wrap it up

RSpec's let expressions are fantastic for creating more readable tests and for reducing some repeated setup code. Using StateDefinition, we can enjoy some of the same benefits in Javascript.

There is more work to do on StateDefinition. I think it could be integrated with Mocha to make usage clearer and more seamless.