Great Expectations – Lesbare Tests mit RSpec
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 Book
s 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 if
s, 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.