Great Expectations – Lesbare Tests mit RSpec
  • 10. October 2019
  • 6 min read
Greatexp1
Bei Railslove schreiben wir viele automatisierte Tests, weil wir finden, dass sich gute Tests im Laufe eines Projekts immer wieder bezahlt machen. Dazu benutzen wir RSpec. Daniel erklärt warum.

RSpec bietet unglaublich viele Features, mit denen man seine Tests zusammenstellen kann. let, let!, subject, described_class, eq, eql, equal, etc etc – Alles eigentlich im Dienste von übersichtlichen, lesbaren Tests.

Oft stützt man sich aber zu sehr auf diese Features und verliert dabei den Inhalt der Tests – den Grund, warum man sie überhaupt schreibt – aus den Augen. Sobald man einige RSpec-Features benutzt hat, sieht der Test bereits „fertig“ aus und bekommt nicht mehr den Feinschliff, den er vielleicht benötigen würde.

Hier als Beispiel (wie in jedem Artikel über Unit Testing) ein Test einer einfachen add Methode:

RSpec.describe Calculator do
  describe '#add' do
    let(:a) { 1 }
    let(:b) { 2 }

    subject { described_class.add(a, b) }

    it 'does the thing' do
      expect(subject).to be(3)
    end
  end
end

Aber: Ein Test sollte nicht nur sicherstellen, ob eine Methode das richtige tut, sondern auch als Beispiel-Sammlung dienen, wie man mit der Methode arbeitet. Darum sollte der Code so realistisch aufgerufen werden wie möglich. Aber wenn man die gleichen Methoden jenseits von Tests – im echten Code – aufruft, benutzt man kein let und kein subject: Man benutzt Variablen.

Warum benutzen wir Variablen dann nicht auch in unseren Tests? Zum Beispiel so:

it 'does the thing' do
  a = 1
  b = 2

  result = Calculator.add(a, b)

  expect(result).to be(3)
end

Schon beim ersten Blick ist ersichtlich, was hier passiert. Der Test ist gut lesbar, weil er aussieht wie echter Code, und nicht wie ein RSpec-Test.

Damit das funktioniert, ist es aber nicht nur wichtig, einen realistischen Testaufbau zu haben, sondern auch, eine verständliche Expectation zu formulieren.

Schauen wir uns dieses (schlechte) Beispiel an:

RSpec.describe Book do
  describe 'group_by_authors' do
    it 'returns authors and their books' do
      author = FactoryBot.create(:author)
      book = FactoryBot.create(:book)

      result = Book.group_by_authors

      expect(result["Author 1"].first.name).to eq("Book 1")
    end
  end
end

Die Expectation ist mir unverständlich (und ich habe das Beispiel geschrieben). Es ist nicht sofort ersichtlich, was in expect landet, oder wo die Werte „Author 1“ und „Book 1“ herkommen. (Spoiler: Aus den Factories. Bitte, wenn ihr etwas expected, dann schreibt es explizit in den Test, und nicht implizit in die Factory).

Man kann natürlich versuchen, sich den Inhalt der Expectation herzuleiten: „Mmh … es gibt wohl Properties … und darin irgendwas, das .first unterstützt (vielleicht ein Array?), und das Element hat dann eine .name Property …“ aber es ist immer möglich, etwas zu übersehen.

Besser ist es, Expectations so vollständig wie möglich zu formulieren:

RSpec.describe Book do
  describe 'group_by_authors' do
    it 'returns authors and their books' do
      author = FactoryBot.create(:author, name: "J. K. Rowling")
      book = FactoryBot.create(:book, name: "Harry Potter", author: author)

      result = Book.group_by_authors

      expect(result).to eq({
        "J. K. Rowling": [book]
      })
    end
  end
end

Bei dieser Expectation wird direkt klar, welche Form das Resultat hat: Ein Hash, der Autorennamen auf Arrays von Books mappt. Dadurch, dass [book] verwendet wird, wissen wir, dass die Methode kein neues Objekt pro Buch erstellt, sondern lediglich die vorhandenen Daten organisiert.

Anhand dieser Expectation kann man sich gut vorstellen, wie die Methode implementiert ist. Das hilft, den vorhandenen Code zu verstehen, kann aber bereits beim Schreiben nützlich sein: Wenn schon vorher klar ist, was von den Methoden erwartet wird, müssen sie häufig nur noch „runtergeschrieben“ werden. Dieser Ansatz funktioniert in unserer Erfahrung besonders gut, wenn es um Methoden geht, deren In- und Outputs klar definiert werden können (Das, was die coolen Kids „Pure Functions“ nennen). Es lohnt sich also, Methoden zu schreiben, die auf Aktionen auf Values ausführen, statt zu sehr auf Side Effects zu setzen.

Doch nicht nur die Expectations können verständlich gestaltet werden: Auch beim Test-Setup hat man viele Möglichkeiten, komplexen Code zu benutzen.

Je weniger ifs, Loops, Mutations, before/after-Hooks, TimeCops, VCRs, Fixtures und verschachtelte Factories es gibt, umso einfacher kann ein Test von Oben nach Unten durchgelesen werden. Das ist, worauf Tests optimiert werden sollten: Ganz normales Lesen™ von Oben nach Unten.

Hier ein Test, den wir bis vor kurzem so ähnlich in unserer Codebase hatten:

RSpec.describe MessageGroup do
  describe 'group_hourly' do
    it 'returns messages grouped by hours' do
      @time_ago = DateTime.current.beginning_of_hour
      10.times do
        @time_ago -= 15.minutes
        FactoryBot.create(:message, sent_at: @time_ago)
      end

      grouped_messages = group_hourly(
        @time_ago,
        DateTime.current
      )

      expect(grouped_messages.groups.length).to eq(3)
    end
  end
end

Abgesehen von der ungenauen Expectation (Die Länge von irgendwas ist 3? Na gut!) ist das ganze Setup hinter einem 10.times-Loop und einer @times_ago-Variable versteckt. Wegen eines Edge Cases mit Timezones mussten wir uns nach mehreren Wochen Pause wieder in diesen Test hineindenken und dabei lange überlegen, was es bedeutet, wenn man von der beginning_of_hour zehnmal 15 Minuten abzieht und das dann nach Stunden gruppiert.

Hier zeigt sich die größte Schwäche von Tests mit zu schlauem Setup: Wenn man später nicht mehr versteht, welche Parameter in eine Methode reingegeben werden, dann kann man auch nicht nachvollziehen, was die Expectation prüft.

Nachdem wir den Timezone Bug gefunden haben, haben wir auch direkt den Test vereinfacht, um es beim nächsten Mal einfacher zu haben:

RSpec.describe MessageGroup do
  describe 'group_hourly' do
    it 'returns messages grouped by hours' do
      message_1 = FactoryBot.create(:message, sent_at: DateTime.parse("2019-01-01 00:59:59"))
      message_2 = FactoryBot.create(:message, sent_at: DateTime.parse("2019-01-01 01:00:00"))
      message_3 = FactoryBot.create(:message, sent_at: DateTime.parse("2019-01-01 01:59:59"))
      message_4 = FactoryBot.create(:message, sent_at: DateTime.parse("2019-01-01 02:00:00"))

      grouped_messages = MessageGroup.new(
        DateTime.new("2019-01-01 00:00"),
        DateTime.new("2019-01-01 03:00"),
        'hour'
      )

      expect(grouped_messages.groups).to eq([
        [message_1],
        [message_2, message_3],
        [message_4]
      ])
    end
  end
end

Wow! Hier ist klar: Wir erstellen vier Messages mit hardcodierten Zeiten, wir gruppieren sie nach Stunden, und die Expectation ist auch klarer: grouped_messages.groups liefert ein Array von Arrays, und wir können direkt prüfen, ob unsere Messages korrekt darin gelandet sind.

Der neue Test ist zwar ein paar Zeilen länger als der alte, aber dafür viel schneller zu lesen. Wenn Code wirklich öfter gelesen als geschrieben wird, dann sollte das ein guter Tausch sein!

Jedenfalls

Wie bei jedem Programmierthema gilt auch hier: Alles ist ein Trade Off. Bei jeder Methode von RSpec hat sich jemand etwas gedacht. Manchmal hat Code einfach Side Effects (sonst könnte man Programmieren auch direkt sein lassen) die getestet werden müssen. Manchmal hat man einfach keine Zeit und ein schnell geschriebener Test ist besser als gar keiner.

Aber wenn man immer mal wieder den eigenen Blick schärft und bei neuen Tests kurz nachdenkt, kann sich ganz neues Vertrauen in die eigene Test Suite entwickeln. Denn: Eine Test Suite, die einem nicht ständig wegen Timezones um die Ohren fliegt, ist eine gute Test Suite.