Când practicăm dezvoltarea test-driven (TDD), uneori avem tendința să ne concentrăm pe testare Tot. Această mentalitate de acoperire 100% ne poate duce uneori la complicarea excesivă a lucrurilor.

Înainte, eu eram cea care conducea sarcina de a face teste DRY-er, pentru că urăsc să văd cod repetitiv. Pe vremea aceea, eram nou în metaprogramarea Ruby și am vrut întotdeauna să fac lucrurile „mai simple”, amestecând codul repetitiv și venind cu un monstru. Caz elocvent:

describe 'when receiving the hero details' do
  it 'should have the top level keys as methods' do
    top_level_keys = %w{id name gender level paragonLevel hardcore skills items followers stats kills progress dead last-updated}

    top_level_keys.each do |tl_key|
      @my_hero.send(tl_key).must_equal @my_hero.response[tl_key.camelize(:lower)]
    end
  end

Bine, deci asta a fost mult înapoi în 2012. Ca fundal, acesta a fost un Bijuterie rubinie (similar cu un pachet npm) pentru Blizzard’s API Diablo 3. Deci, ce testam aici? La citirea codului, pare destul de simplu: spune că tastele de nivel superior pot fi metode. Deci, dacă API-ul returnează ceva de genul:

{
  paragonLevel: 10,
  hardcore: true,
  kills: 1234
}

Apoi, având în vedere un exemplu de erou, le pot numi doar ca metode și ar trebui să le returneze astfel:

> hero = Covetous::Profile::Hero.new 'user#1234', '1234'
> hero.paragon_level # 10
> hero.kills # 1234

Bine, voi fi sincer. Când scriam acest articol, m-am uitat prin vechile mele proiecte open source ca exemplu și am văzut acest lucru. Părea destul de simplu, așa cum am spus, dar, în timp ce îl analizam, mi-am dat seama că era mult mai rău decât credeam. Mi-au trebuit cincisprezece minute să obțin doar ceea ce face, chiar dacă specificația spune ce ar trebui să facă. Înainte de a introduce blocul de mai sus, am vrut să verific de două ori dacă l-am înțeles corect. În timp ce am făcut-o, felul în care am scris testele a făcut totul confuz. De ce?

Problema

Așa cum am spus, atunci eram nou în metaprogramare și am văzut ocazia să o folosesc. La acea vreme, părea foarte inteligent, dar acum, că am mai multă experiență, știu că a face acest lucru în teste este un pasiv mai mult decât un avantaj.

Vedeți, unul dintre lucrurile pe care le-am învățat este că codul de testare este codul netestat. Lasă asta să se scufunde puțin.

Codul de testare este codul netestat.

CODUL TESTULUI ESTE CODUL NESTESAT.

Apropo, acestea sunt două legături diferite. Practic înseamnă că ORICE cod pe care îl rulează testul poate avea propriile erori. De fapt, nu aveți teste pentru codul de test, deci nu există nicio garanție că funcționează. Singura garanție pe care o puteți face este ca testul să eșueze atunci când comentați liniile reale din cod și să îl treceți când îl decomentați. Uneori, însă, chiar și cu acest test roșu-verde, puteți obține în continuare falsuri pozitive. Deci, cel mai bun mod de a evita acest lucru este să vă păstrați testele cât mai simplu posibil și cât mai explicit posibil.

Deci, înapoi la testul meu. Dacă îmi amintesc bine, inițial am făcut metodele una câte una. Am văzut apoi un model care m-a făcut să cred că va fi același model pentru toate metodele cel puțin, așa că de ce să nu fac codul USCAT-er?

Am făcut o serie de toate metodele posibile, am trecut prin ele și am făcut o afirmație că apelarea metodei ar trebui să fie aceeași cu examinarea răspunsului și obținerea valorii. Destul de ușor, dar principalul lucru care m-a dezamăgit a fost următorul:

@my_hero.send(tl_key).must_equal @my_hero.response[tl_key.camelize(:lower)]

În Ruby, send apelează șirul trecut ca metodă. Astfel, dacă tl_keyvaloarea lui a fost paragonLevel (din matrice), această linie spune practic:

@my_hero.paragonLevel.must_equal @my_hero.response['paragonLevel']

Vezi, aici continuă să mă îndoiesc din nou. Ale mele README spune că ar trebui să fie @my_hero.paragon_level, dar uitându-mă la test, nu este. În cine să am încredere acum? Testele mele care trec, sau ale mele README? Acesta este motivul exact pentru care metaprogramarea în teste este periculoasă – nu știi niciodată cu adevărat dacă testele tale trec, fie pentru că sunt corecte, fie pentru că ai configurat-o greșit cumva. Este aproape la fel ca NU scrierea testelor!

Făcându-l într-un mod mai bun

Deci, cum aș re-scrie asta? De atunci am învățat că testele de scriere pentru sinele meu de zece ani ar fi suficient. Adică eu însumi acum zece ani. Mă întreb mereu: „Peste zece ani, aș mai fi în stare să înțeleg asta, fără context?” Dacă nu, atunci asta înseamnă că fie trebuie să scriu o notă în comentarii sau testul meu este prea complicat.

Să încercăm să rescriem acest lucru. După cum am spus, ar trebui să fim cât se poate de simpli și de expliciți. Iată o soluție:

# Given I queried my hero against the API:
let(:my_hero) { Covetous::Profile::Hero.new 'corroded-6950', '12345678' }
it 'should have the top level keys as methods' do
  expect(my_hero.id).to eq 12345
  expect(my_hero.name).to eq 'corrodeath'
  expect(my_hero.gender).to eq 'female'
  expect(my_hero.level).to eq 70
  ...
end

Vedeți cât de explicit este? Este repetitiv, sigur, dar peste 10 ani de acum sunt destul de sigur că aș mai înțelege care erau așteptările mele. Nu trebuie să „compilez și să interpretez” codul din creierul meu. Tocmai am citit specificațiile!

De asemenea, cu asta, nici nu a trebuit să-mi amintesc ce camelize(:lower) de fapt o face (mărturisire: a trebuit să o caut în timp ce citeam codul meu vechi).

Ce zici de un alt exemplu? Deci, având în vedere, avem un model:

class Something < ActiveRecord::Base
  VALID_THINGS = %w(yolo swag)
  OTHER_VALID_THINGS = %w(thing another_thing)
  def valid_things_ids
    where(group: group).pluck(:id)
  end
end

Cele de mai sus sunt doar un exemplu inventat bazat pe o clasă reală pe care o avem în compania mea actuală. Specificația pe care am văzut-o a fost următoarea:

subject(:valid_things_ids) { described_class.valid_things_ids(group) }

let(:group) { 'example' }

before do
  described_class::VALID_THINGS.each do |thing|
    FactoryGirl.create(:something, group: 'example', name: thing)
  end
end

described_class::VALID_THINGS.each do |thing|
  it "contains things with the name #{thing}" do
    the_thing = described_class.find_by_group_and_name('example', thing)
    expect(valid_things_ids).to include the_thing.id
  end
end

Bine. În primul rând, acesta este un test corect, în care dat un număr de somethings, putem apela metoda și ne returnează toate ID-urile somethings cu acel grup (de ex example).

Cu toate acestea, problema mea este că trebuie să testăm toate lucrurile valabile? Ce ziceti OTHER_VALID_THINGS? Dacă vrem să testăm toate valorile posibile ale VALID_THINGS , atunci ar trebui să testăm și toate valorile posibile ale OTHER_VALID_THINGS. Dacă nu vrem să testăm toate valorile posibile, atunci de ce să folosim VALID_THINGS? De ce nu inventăm doar un eșantion aleatoriu și doar dovedim că metoda funcționează?

Ce zici de așa ceva?

subject(:valid_things_ids) { described_class.valid_things_ids(group) }

let(:group) { 'blurb' }

let!(:random_thing) { FactoryGirl.create(:something, group: 'blurb', id: 111) }
let!(:another_thing) { FactoryGirl.create(:something, group: 'blurb', id: 222) }
let!(:not_included) { FactoryGirl.create(:something, group: 'shrug', id: 333) }

it do
  expect(valid_things_ids).to include 111
  expect(valid_things_ids).to include 222
  expect(valid_things_ids).not_to include 333
end

Deci, aici, creez 3 somethings și dă-le id-uri. Îl fac pe al treilea să aibă un grup diferit. Acum, dacă rulez metoda cu blurb ca argument, mă pot aștepta să includă primele două și nu ultima.

Citind-o peste câteva luni, nu voi fi confuz cu privire la ceea ce este testat, deoarece este simplu și nici nu trebuie să mă întreb de ce testez doar o anumită parte a codului și nu toate.

De asemenea, luați notă de explicitatea testului. Mă aștept să includă ID-urile 111 și 222. În mod normal, oamenii ar testa-o astfel:

expect(valid_things_ids).to include random_thing.id

Nu prea îmi plac aceste teste, pentru că încă se bazează pe cod în acest moment. Dacă dintr-un anumit motiv id-ul este nil, iar codul a avut, de asemenea, o eroare unde a revenit nil, atunci acest test ar trece în continuare. Totuși, nu cu identificări și așteptări explicite. Desigur, vor exista avertismente, dar cred că aș vrea să mă ocup de acestea, mai degrabă decât de incertitudinea posibililor fals pozitivi.

Înfășurându-se

După cum puteți vedea din ambele exemple de mai sus, testele simple de citit vă vor ajuta pe termen lung. A fi foarte explicit ajută foarte mult la înțelegerea testelor și la mai puține bug-uri.

Amintiți-vă, acoperirea testului de 100% nu va conta dacă jumătate dintre acestea sunt fals pozitive. Amintiți-vă întotdeauna de sinele vostru trecut când testați. Încearcă să te gândești cu mult înainte în viitor și să te întrebi ce înseamnă testele tale.