Skip to content

Latest commit

 

History

History
594 lines (459 loc) · 20.8 KB

example_test.md

File metadata and controls

594 lines (459 loc) · 20.8 KB

An example test with Mocktail

As discussed elsewhere, Mocktail's intended use is to facilitate test-driven development of isolated unit tests by making it trivially easy to achieve isolation between a subject under test and its dependencies.

A small example problem: slicing fruit

In order to walk through an example test, we need to dream up a class and method to implement that is simple enough to condense into example code but realistic enough to show off the workflow Mocktail was designed to facilitate. And to do that, it's useful context to start with the end in mind. In a codebase that was primarily developed with outside-in test-driven development, it's typical to see the following:

  • The average delegator has 3 dependencies that implement logic
  • Delegators are typically the only units of code whose tests use test doubles, and they tend to simplify their usage by only delegating as opposed to performing any logic, thereby avoiding mixed levels of abstraction
  • Because most real-world applications don't exist in a vacuum, a given entrypoint for a unit of work will typically shake out into a tree of dependencies that resembles an extract, transform, load process

With that in mind, let's write a test of a delegator that assists in the prep work before a bar opens: getting fruits from the fridge, slicing them, and storing them in a prep station.

One way to identify the jobs to be done is to translate a narrative summary of a feature into a list of discrete responsibilities or activities. A class that manages fruit prep would:

  1. Fetch fruits from the stock room
  2. Slice each fruit
  3. Store each fruit in a prep station

If we were practicing traditional test-driven development, we might start with a test that jumps into implementing one of these activities and then continue to add test cases until all the responsibilities are covered. If the subject were to get too complex as we saddled it with more tasks, we might pause to extract those responsibilities into single-responsibility sub-units, which would often necessitate moving test code around or otherwise testing those sub-units only indirectly through the original subject.

Outside-in test driven development flips the order of operations: once we know what the subject needs to do, we imagine new subordinate units to delegate the work to and use our test to specify the relationship between the subject and its dependencies. We would repeat this process until we were left with a number of irreducibly simple, single-responsibility units of domain logic that could largely be implemented via straightforward tests (without mocks), often as pure functions. This way, simplicity is baked into the process instead of an afterthought—where classical TDD often succeeds or fails based on the practitioner's patience and discipline to remember to refactor.

To make this a little clearer, let's illustrate the ordered list above into imagined dependencies that a delegator might need to accomplish the work:

an example tree of dependencies that do the above work

The diagram matches the assumptions of a typical delegator described above: a subject with 3 dependencies, that does nothing but delegate its responsibility, and a tree that resembles an extract-transform-load process: something to fetch data based on a request, something to perform logic, and something to put the result somewhere.

To visualize what we mean by ETL:

illustrating the tree as an ETL process

If you're new to isolated TDD, the focus on these heuristics may appear like arbitrary strictures but in reality are just observations of patterns that emerge over time when your practice and tools pushes you to the extreme end of breaking down problems into small, single-purpose functions and methods. The reason ETL emerges as a common pattern is because a huge proportion of software features entails getting data via I/O, doing something interesting with that data, and then putting that data somewhere.

Initial test setup

Okay, so with that gameplan, let's start writing a test. We will use dependency inception to get our dependencies in the hands of our subject using Mocktail.of_next:

require "test_helper"

class PrepsFruitsTest < Minitest::Test
  def setup
    @fetches_fruits = Mocktail.of_next(FetchesFruits)
    @slices_fruit = Mocktail.of_next(SlicesFruit)
    @stores_fruit = Mocktail.of_next(StoresFruit)

    @subject = PrepsFruits.new
  end

  def test_prep
    # TODO
  end
end

If we run this test, of course it will fail, because none of the things referenced in setup exist yet! That said, it's important to get in the habit of running tests early and often to make sure that their message and status matches our expectations.

PrepsFruitsTest#test_prep:
NameError: uninitialized constant PrepsFruitsTest::FetchesFruits
    example.rb:5:in `setup'

As they say, the goal of each action in TDD is to "either make the test pass or change the message", so let's rapidly iterate to overcome each of these errors until the empty test_prep method exits cleanly:

class FetchesFruits
end

Fails with uninitialized constant PrepsFruitsTest::SlicesFruit until we:

class SlicesFruit
end #=> message is now: `uninitialized constant PrepsFruitsTest::StoresFruit`

class StoresFruit
end #=> message is now: `uninitialized constant PrepsFruitsTest::PrepsFruits`

class PrepsFruits
end #=> …success!

Creating these 4 classes (the subject and its three dependencies) is enough to get the test to exit cleanly for now.

Arrange: creating values and configuring stubbings

Each test has three phases: arrange, act, and assert, and it usually makes the most sense to start with test setup. Here we'll need to create the values that our subject will be acting on as well as the stubbing configurations for each of its mocked dependencies.

Like before, we'll rapidly iterate by adding a stubbing, running the test, making a change, and changing the message. After you get in the flow, driving the design of a system with outside-in test-driven development starts to feel like paint by number.

We'll start by stubbing the first interaction to fetch the fruit. This will require us to specify some value objects (fruits) we haven't created yet.

def test_prep
  fruits = [Lime.new, Mango.new, Pineapple.new]
  stubs { @fetches_fruits.fetch([:lime, :mango, :pineapple]) }.with { fruits }
  # …TODO
end

The first error is uninitialized constant PrepsFruitsTest::Lime, so let's start fixing:

class Lime
end # message is now: uninitialized constant PrepsFruitsTest::Mango

class Mango
end # message is now: uninitialized constant PrepsFruitsTest::Pineapple

class Pineapple
end

After defining Pineapple, the next message becomes more interesting!

No method `FetchesFruits#fetch' exists for call:

  fetch([:lime, :mango, :pineapple])

Need to define the method? Here's a sample definition:

  def fetch(lime_mango_pineapple)
  end

Mocktail can see what we're stubbing and tries to guess the number and name of arguments based on what we passed in, generating a little method for us. We can paste that in or we can write our own to clear the error and change the message:

class FetchesFruits
  def fetch(types)
  end
end

And now we're back to passing! Let's move onto the next stubbing. This one is much fancier, so look closely:

def test_prep
  fruits = [Lime.new, Mango.new, Pineapple.new]
  stubs { @fetches_fruits.fetch([:lime, :mango, :pineapple]) }.with { fruits }
  stubs { |m| @slices_fruit.slice(m.is_a(Fruit)) }.with { |call|
    SlicedFruit.new(call.args.first)
  }
  # …TODO
end

This stubbing is taking advantage of two advanced features in Mocktail:

  • Argument matchers - the m.is_a matcher allows the stubbing to do triple-duty and results in the stub being satisfied any time a Fruit instance is passed to @slices_fruit.slice
  • Call introspection - with receives an optional Call block param that represents an actual call of the mocked method by the subject whenever it satisfies the stub configuration

If you're not used to reading Mocktail's API yet, these two stubbings can be combined to facilitate production code like this:

@fetches_fruits.fetch([:lime, :mango, :pineapple]).map { |fruit|
  @slices_fruit.slice(fruit)
} #=> returns [SlicedFruit(Lime), SlicedFruit(Mango), SlicedFruit(Pineapple)]

But initially, this will raise a few new errors for us to clear, starting with uninitialized constant PrepsFruitsTest::Fruit. Let's clear it:

class Fruit
end

class Lime < Fruit
end

class Mango < Fruit
end

class Pineapple < Fruit
end

Here's the next error:

No method `SlicesFruit#slice' exists for call:

  slice(is_a(Fruit))

So let's go and create one!

class SlicesFruit
  def slice(fruit)
  end
end

And we're back to passing.

Act: invoking our subject

Impatient readers will note in frustration that we've created eight classes but still haven't even defined the method to be tested, PrepsFood#prep!

Now that our basic stubs are in place, let's invoke that method as our test's "act" phase:

def test_prep
  fruits = [Lime.new, Mango.new, Pineapple.new]
  stubs { @fetches_fruits.fetch([:lime, :mango, :pineapple]) }.with { fruits }
  stubs { |m| @slices_fruit.slice(m.is_a(Fruit)) }.with { |call|
    SlicedFruit.new(call.args.first)
  }

  @subject.prep([:lime, :mango, :pineapple])

  # …TODO
end

As you might expect, the test is telling us to create the method we're here to implement: undefined method 'prep' for #<PrepsFruits:0x0000000105008060>, so let's make it:

class PrepsFruits
  def prep(fruit_types)
  end
end

And we're passing again.

That's it! The "Act" phase is usually a one line invocation, because ideally the subject should be able to do its job being told only once.

Assert: verifying our interactions

Now that we've completed the Arrange and Act, we can deal with the Assert phase of our test.

But how should we assert that the sliced fruit gets stored? None of the code exists yet, so the assertion we encode into our test will specify the API of the class responsible for storing fruit. This can make some folks feel a little queasy, because in the context of traditional TDD, the subject should be a black box—meaning tests should not be aware of, much less determine the subject's implementation details. But isolated TDD flips things inside-out: the test becomes a sounding board for iterating rapidly on the public APIs of not only the subject, but of the layer of dependencies beneath it. This gives the test author the opportunity to play with a new API contract (name, parameters, and return value) via a lightweight demonstration of a method that doesn't even exist yet, which means every new method is created through actual, necessary use instead of being typed into a blank class listing. Letting usage determine the API confers the same benefits as readme-driven development by working outside-in and making mistakes cheap to fix. If a method doesn't feel right, reconfiguring the stubbing in place doesn't require switching files, renaming references, or moving parameters around. Put differently, if refactoring is the third step in classical TDD's "red-green-refactor", it's the first step when practicing isolated TDD. (Prefactoring?)

To illustrate how the assertions we choose can impact the API of our subject and its dependencies, we're going to show two different ways to finish writing this test, starting with an approach that verifies a call occurred and finishing with assertions of a return value from the subject.

Approach 1: verifying the method was called

One approach would be to verify that StoresFruit#store is invoked for each SlicedFruit instance using verify. Let's play that out here.

def test_prep
  fruits = [Lime.new, Mango.new, Pineapple.new]
  stubs { @fetches_fruits.fetch([:lime, :mango, :pineapple]) }.with { fruits }
  stubs { |m| @slices_fruit.slice(m.is_a(Fruit)) }.with { |call|
    SlicedFruit.new(call.args.first)
  }

  @subject.prep([:lime, :mango, :pineapple])

  verify { |m|
    @stores_fruit.store(m.that { |sliced_fruit| sliced_fruit.type == Lime })
  }
  verify { |m|
    @stores_fruit.store(m.that { |sliced_fruit| sliced_fruit.type == Mango })
  }
  verify { |m|
    @stores_fruit.store(m.that { |sliced_fruit| sliced_fruit.type == Pineapple })
  }
end

Above, we're using another matcher, m.that, to ensure that each store was called with one of each type of fruit. (m.that takes a block parameter that receives the actual argument it stands in for, passing the verification when it returns truthy and failing it otherwise.)

We could have implemented this verification more simply by assigning SlicedFruit instances in the test and stubbing & verifying them by reference. This would have added another set of variables to track, but would eliminate the need for using any m argument matchers. (The purpose of these docs is to teach Mocktail's API, so it errs on the side of leaning harder into the library's features.)

Running our test yields our next error No method 'StoresFruit#store' exists. We can fix that:

class StoresFruit
  def store(fruit)
  end
end

Finally, this yields an actual assertion failure:

Mocktail::VerificationError: Expected mocktail of `StoresFruit#store' to be called like:

  store(that {…})

But it was never called.

This is a big deal! It means it's finally time to start implementing the subject method based on all the decisions driven out by our test so far. We can write most of these interactions in one fell swoop, because the test setup already forced us to make most of the decisions of consequence about the code itself.

Here's how the PrepsFruit class might shake out:

class PrepsFruits
  def initialize
    @fetches_fruits = FetchesFruits.new
    @slices_fruit = SlicesFruit.new
    @stores_fruit = StoresFruit.new
  end

  def prep(fruit_types)
    @fetches_fruits.fetch(fruit_types).each do |fruit|
      sliced_fruit = @slices_fruit.slice(fruit)
      @stores_fruit.store(sliced_fruit)
    end
  end
end

This may feel like a lot of code to write in one go, but it was all preordained by the test, so it kind of just writes itself.

Does it work? No! But it changed the message to something we might not have realized we hadn't created yet: uninitialized constant PrepsFruitsTest::SlicedFruit.

This is actually good news! Because SlicedFruit.new is only refrenced inside a stubs…with {} block, it means the implementation above is successfully invoking the first two of three dependencies.

Let's implement our SlicedFruit value object next, noting that its initializer takes a basic Fruit object and (per what we specified in our verify block), exposes the fruit's class via a type method:

class SlicedFruit
  def initialize(fruit)
    @fruit = fruit
  end

  def type
    @fruit.class
  end
end

And… boom! The test passes. That means all of our setup and assertions worked and the implementation passes the test!

That said, never trust a test you haven't seen fail. To be sure the test's passing isn't an indication of a faulty assertion, let's jiggle the handle by tweaking one of those verify calls:

verify { |m|
  @stores_fruit.store(m.that { |sliced_fruit| sliced_fruit.type == :nonsense })
}

Running the test again, we get an error that tells us that the code is doing exactly what we want… yay!

Mocktail::VerificationError: Expected mocktail of `StoresFruit#store' to be called like:

  store(that {…})

It was called differently 3 times:

  store(#<SlicedFruit:0x00000001036357a0 @fruit=#<Lime:0x00000001036d1998>>)

  store(#<SlicedFruit:0x000000010622df10 @fruit=#<Mango:0x00000001036d1808>>)

  store(#<SlicedFruit:0x0000000106226580 @fruit=#<Pineapple:0x00000001036d16f0>>)

We could stop here and call the job done. We have a working PrepsFruit class that does what it set out to do by loading, slicing, and storing fruits. But because this is a tutorial, let's take a moment to reflect on where this test led us and how things could have played out differently.

Approach 2: asserting a result value

Because each doesn't return a meaningful value, whenever we see an each block, whatever it's doing must be a side effect. And since side effects are generally less desirable than returning values, it's worth pausing and asking if there was a different approach we could have taken.

It turns out, there is! Even though Mocktail offers a robust verify method, it should be used sparingly, because—like each—its only real utility is to specify interactions that don't return a value. Methods that return values are generally more useful, so let's rewind the clock, delete the production code we just wrote, and set up our assertions to interrogate a return value instead of verifying calls to StoresFruit#store.

Here's where the test stood after completing the Act phase:

def test_prep
  fruits = [Lime.new, Mango.new, Pineapple.new]
  stubs { @fetches_fruits.fetch([:lime, :mango, :pineapple]) }.with { fruits }
  stubs { |m| @slices_fruit.slice(m.is_a(Fruit)) }.with { |call|
    SlicedFruit.new(call.args.first)
  }

  result = @subject.prep([:lime, :mango, :pineapple])
end

And here's an alternate assertion we might have written, this time stubbing StoresFruit#store and asserting the return value:

def test_prep
  fruits = [Lime.new, Mango.new, Pineapple.new]
  stubs { @fetches_fruits.fetch([:lime, :mango, :pineapple]) }.with { fruits }
  stubs { |m| @slices_fruit.slice(m.is_a(Fruit)) }.with { |call|
    SlicedFruit.new(call.args.first)
  }
  stubs { |m| @stores_fruit.store(m.is_a(SlicedFruit)) }.with { |call|
    StoredFruit.new("ID for #{call.args.first.type}", call.args.first)
  }

  result = @subject.prep([:lime, :mango, :pineapple])

  assert_equal 3, result.size
  assert_equal "ID for Lime", result[0].id
  assert_equal Lime, result[0].fruit.type
  assert_equal "ID for Mango", result[1].id
  assert_equal Mango, result[1].fruit.type
  assert_equal "ID for Pineapple", result[2].id
  assert_equal Pineapple, result[2].fruit.type
end

In the reimagined test above, we decided that instead of being a fire-and-forget call, a value representing a stored fruit with a unique ID could be returned from StoresFruit.store. Of course, the stubbing could have mutated the SlicedFruit passed to it (just as SlicedFruit#slice could have mutated the Fruit it received), but if we're going to go to great lengths to return values instead of have side effects, we may as well go the extra mile and avoid mutating those values.

After clearing out PrepsFruit#prep, the test will error with undefined method 'size' for nil:NilClass, since it's not returning anything.

Let's try our hand at a new implementation that should pass the test:

def prep(fruit_types)
  @fetches_fruits.fetch(fruit_types).map { |fruit|
    fruit = @slices_fruit.slice(fruit)
    @stores_fruit.store(fruit)
  }
end

But it errors! The StoredFruit value hasn't been created yet. Let's clear the uninitialized constant PrepsFruitsTest::StoredFruit message by implementing it as a Struct:

StoredFruit = Struct.new(:id, :fruit)

And now things are passing!

If we're paranoid, we can quickly check that everything's working by changing out one of the test's final assertions to see the failure it produces:

assert_equal "ID for Zebras", result[2].id
#=>
# PrepsFruitsTest#test_prep [example.rb:29]:
# Expected: "ID for Zebras"
#   Actual: "ID for Pineapple"

And that's the kind of failure that we'd expect to see if everything was working as we expected. Great job!

There are a few places you could explore next:

Run the test code for this tutorial yourself.

Learn about Mocktail's debugging and introspection APIs.

Check out an advanced feature not covered in this guide: argument captors.