Logo_developerfounder

« See the homepage for more articles.

Met de GroundControl gem ben ik bezig een standalone build/test worker te bouwen die je kan gebruiken op je eigen server of bijvoorbeeld in je eigen test scripts kan gebruiken. Met GroundControl moet het dus mogelijk zijn om in principe je eigen manier van Continuous Integration te kunnen doen, inclusief headless tests.

Na het bouwen van mijn eerste prototype wilde ik de 80% "succes"-case unit testen en kwam ik bij het probleem aan hoe je private methods dient testen als je aan het unit testen bent of Test Driven Development volgt.

Eén grote Builder class met één public build method

De class GroundControl::Builder is het belangrijkst en de hoofdmoot van de gem. Hoe je deze gebruikt is als volgt:

project_build = GroundControl::Builder.new("projectnaam", {"git" => "git repository"})
report = project_build.build
puts report.test_results

De method build is op deze class de enige method die public is en als externe interface aangeboden wordt in de code.

Dit is een samenvatting van wat deze method doet:

  • workspace en directories aanmaken om de build te gaan runnen
  • git repository clonen en open houden om commits en dergelijken uit te kunnen lezen tijdens het builden
  • instellen van database.yml om de build te kunnen gaan uitvoeren
  • instellen van sphinx.yml om tijdens de build ThinkingSphinx search functies uit te voeren
  • toevoegen van een ci_reporter rake hook om straks unit test resultaten in te kunnen lezen vanuit XML files
  • installeren van benodigde projectgems met Bundler
  • runnen van unit tests rake task
  • runnen van cucumber tests rake task inclusief opstarten van een X virtual frame buffer om met selenium te kunnen testen
  • inlezen van een Report object met alle testresultaten

Oftewel, als je ziet wat deze method uiteindelijk allemaal zou moeten doen zul je het met me eens zijn dat alles zomaar in deze method gooien een draak van een stuk code op zal leveren. Die code moet waarschijnlijk verdeeld worden in allerlei private methods.

Maar, unit testen doe je in principe op uitvoerbare methods in je public interface. Toch moet elk stukje code uit deze lijst afzonderlijk ge unit test worden.

Hoe zorg je dan dat je toch onderdelen in deze public method kan testen?

Na wat tweets van en lezen op blogs kwam ik tot de volgende conclusie/consensus:

Bij het doen van Test Driven Development beschouw je initieel alles als public en zul je in je refactor stap methods private kunnen maken.

Mijn aanpak was daarom als volgt:

  • Voor elke verantwoordelijkheid in mijn specs lijstje minimaal een eigen unit test hebben.
  • Unit test voor een enkele verantwoordelijkheid schrijven, die in elke unit test de public "build" method aan roept.
  • Code in het public method schrijven die de unit test laat passen.
  • De verantwoordelijkheid van elke test refactoren naar één of meerdere private methods.

Voor deze laatste stap gebruik ik in principe de stelregel: Elke method dient één verantwoordelijkheid te hebben en niet meer dan één. (Single Reponsibility Principle)

Het volgen van deze stappen en het gebruiken van deze laatste stelregel brengt nog een aantal andere voordelen mee. Verder op meer daarover.

Schrijven van de eerste unit test

Voor het unit testen van GroundControl maak ik gebruik van Ruby's built-in TestUnit framework. Mijn basisbestand ziet er voor deze class als volgt uit:

module GroundControl

  class BuilderTest < Test::Unit::TestCase

    def setup
      if File.exists?(File.expand_path("builds/cool_rails_project"))
        FileUtils.rm_r(File.expand_path("builds/cool_rails_project"))
      end

      @builder = Builder.new("cool_rails_project", {"git" => "test/repositories/dot_git_rails"}         
    end

    def test_een_verantwoordelijkheid
      # Eventuele stubs + setup

      @builder.build # Mijn enige public method

      # Assertions

    end
  end
end

Als we nu de volgende verantwoordelijkheid uit mijn specificatie-lijstje pakken: workspace en directories aanmaken om de build te gaan runnen dan komt daar de volgende unit test uit:

def test_build_creates_workspace_directories
  @builder.build

  assert File.exists?(File.expand_path("builds/cool_rails_project")), "Expected directory builds/cool_rails_project to exist."
end

Deze test faalt nu uiteraard, omdat mijn build method nog niets doet met het aanmaken van deze workspace directory. Op dus naar de volgende stap: stukje code toevoegen aan mijn build method.

module GroundControl
  class Builder
    def new(project_name, config)
      @workspace = File.expand_path(File.join("builds", project_name))
    end

    def build
      FileUtils.mkdir_p(@workspace)
    end
  end
end

Dit stuk code moet genoeg zijn om mijn test te laten passen. Gelukkig doet deze dat ook.

In principe hebben we nu het volgende gedaan:

  • Een test maken voor een verantwoordelijkheid in mijn specs lijst.
  • In de test de public build method aanroepen.
  • Code schrijven in de public method method die de test laat passen.

Nu komen we aan bij de refactor stap waarin we de verantwoordelijkheid zouden kunnen refactoren naar een eigen private method in de class.

Om mijn illustratie met splitsing van verantwoordelijkheden nog net iets duidelijker te maken wil ik eerst de tweede verantwoordelijkheid maken en die twee daarna samen refactoren.

Voorbeeld van tweede unit test en refactoren naar private methods

De tweede verantwoordelijkheid is als volgt: git repository clonen en open houden om commits en dergelijken uit te kunnen lezen tijdens het builden.

Hiervoor de volgende test:

def test_build_clones_the_repository
  @builder.build

  assert File.exists?(File.expand_path("builds/cool_rails_project/README")), "Expected builds/cool_rails_project/README to exist after git clone"
end

Wat ik in deze test controleer is of het README-bestand bestaat na het clonen van de Git repository. In mijn test Git repository komt namelijk het bestand README voor.

Nu voeg ik om deze test te laten passen de volgende code toe:

def new(project_name, config)
  @workspace = File.expand_path(File.join("builds", project_name))
  @build_directory = File.join(@workspace, "build")
  @git_url = config['git']
  @repository = nil
end

def build
  FileUtils.mkdir_p(@workspace)

  grit = Grit::Git.new('/tmp/grit-filler')
  grit.clone(@git_url, @build_directory)

  @repository = Grit::Repo.new(@build_directory)
end

Deze code ziet er op zich duidelijk uit en de test passed want de clone wordt uitgevoerd met mijn test repository waar de README file in voor komt.

We hebben nu echter de implementaties van twee verantwoordelijkheden openbaar in de build method staan. Dit is dus een mooi moment om deze twee verantwoordelijkheden uit te gaan splitsen in twee private methods als volgt:

def new(project_name, config)
  @workspace = File.expand_path(File.join("builds", project_name))
  @build_directory = File.join(@workspace, "build")
  @git_url = config['git']
  @repository = nil
end

def build
  create_workspace
  clone_repository
end

private

def create_workspace
  FileUtils.mkdir_p(@workspace)
end

def clone_repository
  grit = Grit::Git.new('/tmp/grit-filler')
  grit.clone(@git_url, @build_directory)

  @repository = Grit::Repo.new(@build_directory)
end

De build method is op dit niveau nu duidelijk. Wat deze method doet is het aanmaken van een workspace en het clonen van de git repository. Hoe deze het doet? Maakt me geen bal uit, als het maar gebeurt. Dat mogen de private methods uitzoeken.

Omdat de code inhoudelijk kwa implementatie niets is veranderd maar de structuur van verantwoordelijkheden duidelijker is geworden zullen de unit tests nog steeds goed draaien.

Het grootste voordeel van refactoren naar private methods: stubben

Stukje nette code waarvan de verantwoordelijkheden duidelijk gescheiden zijn. Best wel tof al. Maar dit levert nog een ander voordeel op.

We hebben nu namelijk het nadeel dat bij het uitvoeren van de unit test voor het aanmaken van de workspace, óók de Git repository gecloned wordt. Naast dat dit uiteindelijk traag wordt is de unit test nu ook niet meer atomair. Want: als er iets mis gaat bij het clonen van ons repository zal de unit test die enkel directories dient te checken ook falen, terwijl daar misschien helemaal niets mis mee is.

Omdat we nu twee private methods hebben met allebei hun verantwoordelijkheid kunnen we per unit test één van de twee netjes stubben: het "faken" van de method alsof deze uit wordt gevoerd zodat je alleen test wat je écht wilt testen.

Na het gebruiken van de gem mocha kunnen we onze eerste test nu schrijven als volgt:

def test_build_creates_workspace_directories
  @builder.stubs(:clone_repository)

  @builder.build

  assert File.exists?(File.expand_path("builds/cool_rails_project")), "Expected directory builds/cool_rails_project to exist."
end

We stubben nu de method clone_repository waardoor deze niet meer echt uitgevoerd wordt en we het aanmaken van de workspace toch kunnen testen.

Oftewel, het volgen van deze TDD cycle en het refactoren naar private methods zorgt voor de volgende voordelen:

  • We kunnen test-driven een enkele public method met ontzettend veel verantwoordelijkheden testen.
  • We zorgen dat iedere method zijn eigen verantwoordelijkheeft en toch geunit-test kan worden vanuit één public method.
  • We kunnen onze tests opschonen en atomair houden door private methods die we niet nodig hebben voor een unit test uit te stubben.