Benito Serna
Trying to build software that works

Not using end-to-end tests in your TDD cycle

As I wrote in the previous article about using end-to-end tests in your TDD cycle… This kind of tests can be very helpful in some situations, and I think that is a good thing to try if it works for you and your current project.

I actually used the end-to-end tests to start my TDD cycle, more ore less like in the GOOS book, for a lot of time and projects… But to be honest I normally don’t put end-to-end tests in my TDD workflow. I normally prefer to write this kind of tests after the implementation, and not for every feature.

The problem with not using end-to-end tests is that that you could end with tests for a lot small objects interacting with each other, that in my opinion normally that’s a good thing, but with no evidence that you have wired them together in the right way =S

So to tackle this problem I almost always try to design “entry” points for my application code, in similar way of what some people call “Application services” or “Ports”…

In this way if in a feature dozens of different kind of objects/components will interact with each other… I try to “pack” them in this kind of commands to the application that I call use case functions. Resulting in 1 to 3 commands (or sometimes a little more) that take place in a bigger feature.

… And this things are the things that I write tests for! So instead of having independent tests for each piece of my “model” I test to 1 to 3 different actions in the application…

This help me to…

A little example

In a guest list app, we have a function to add an invitation… That we call in the controller like this…

class InvitationsController < ApplicationController
  def new
    render locals: { list_id: list_id, invitation_form: Lists.get_invitation_form }
  end

  def create
    status = Lists.add_invitation(list_id, params[:invitation], ListInvitationRecord)

    if status.success?
      # render list
    else
      render status: 422, locals: { list_id: list_id, invitation_form: status.form }
    end
  end
end

And we have a test file like this…

require_relative "../lists_spec"

module Lists
  describe "Add invitation" do
    def invitation_with(attrs)
      {id: rand(100)}.merge(attrs)
    end

    def store_with(records)
      FakeInvitationsStore.new(records)
    end

    it "has a form with some attributes..." do
      form = Lists.get_invitation_form
      expect(form.title).to eq nil
      expect(form.phone).to eq nil
      expect(form.group).to eq nil
      expect(form.email).to eq nil
      expect(form.guests).to eq nil
    end

    describe "with good params" do
      attr_reader :store, :list_id, :params

      before do
        @store = store_with([])
        @list_id = 1234
        @params = {
          "title" => "Uno",
          "guests" => "Benito, Maripaz",
          "group" => "Amigos Benito",
          "phone" => "1234-1234",
          "email" => "bh@example.com"
        }
      end

      it "creates a record" do
        expect(store).to receive(:create).with({
          list_id: list_id,
          title: "Uno",
          group: "Amigos Benito",
          guests: "Benito, Maripaz",
          phone: "1234-1234",
          email: "bh@example.com"
        }).and_call_original

        Lists.add_invitation(list_id, params, store)
      end

      it "returns success" do
        status = Lists.add_invitation(list_id, params, store)
        expect(status).to be_success
      end

      it "returns the id of the created record" do
        allow(store).to receive(:create).and_return(invitation_with(id: "234"))
        status = Lists.add_invitation(list_id, params, store)
        expect(status.invitation_id).to eq "234"
      end
    end

    describe "without a title" do
      attr_reader :store, :list_id, :params

      before do
        @store = store_with([])
        @list_id = 1234
        @params = {
          "title" => "",
          "guests" => "Benito, Maripaz",
          "group" => "Amigos Benito",
          "phone" => "1234-1234",
          "email" => "bh@example.com"
        }
      end

      it "does not create the record" do
        expect(store).not_to receive(:create)
        Lists.add_invitation(list_id, params, store)
      end

      it "does not return success" do
        status = Lists.add_invitation(list_id, params, store)
        expect(status).not_to be_success
      end

      it "returns a blank error" do
        status = Lists.add_invitation(list_id, params, store)
        form = status.form
        expect(form.errors[:title]).to eq "no puede estar en blanco"
      end

      it "keeps the sent params" do
        status = Lists.add_invitation(list_id, params, store)
        form = status.form
        expect(form.title).to eq ""
        expect(form.guests).to eq "Benito, Maripaz"
        expect(form.group).to eq "Amigos Benito"
        expect(form.phone).to eq "1234-1234"
        expect(form.email).to eq "bh@example.com"
      end
    end
  end
end

And an implementation like this…

module Lists
  #....

  def self.add_invitation(list_id, params, store)
    form = InvitationForm.new(params)
    errors = NewInvitationValidator.validate(form)

    if errors.empty?
      record = store.create(form.to_h.merge(list_id: list_id))
      InvitationCreatedResponse.new(record[:id])
    else
      form.add_errors(errors)
      ErrorWithForm.new(form)
    end
  end
end

As you can see we have several objects talking to each other, but we are not testing them directly…

Some conclusions

For me this feels like writing unit tests as feature tests, but you know, naming is hard…

I have found this kind of use case functions very helpful and I hope you can take some ideas and apply something =)

Do you need some help with TDD?

I have an email course with with a guide to help you start with TDD!


Do you want to know more?