Igor Chulinda: Czy TDD jest złe?

Igor Chulinda: Czy TDD jest złe?

Wyjaśnienie: To clickbait. Oczywiście, TTD nie jest złe, ale… Zawsze jest jakieś ale.

Przez pierwsze 6 lat mojej kariery byłem freelancerem i pracowałam dla start-upów w początkowej fazie. Żadnych testów… Naprawdę – ani jednego.

W takich okolicznościach trzeba dostarczać funkcje na wczoraj. Ponieważ wymagania rynku ciągle się zmieniają, testy stają się przeterminowane zanim zdążysz je skończyć. I nawet takie testy mogą być napisane tylko wtedy, kiedy wiesz, co chcesz stworzyć. A nie zawsze tak jest. Na etapie R&D możesz nie wiedzieć, jaki jest twój ostateczny cel. A kiedy docierasz do konkretnego punktu, nie masz pewności, że jutro nie nadejdzie konieczność wprowadzenia diametralnej zmiany. Tak naprawdę, z biznesowego punktu widzenia, oszczędzanie czasu poprzez unikanie testów jednostkowych jest więc uzasadnione.

Ok, nasza branża jest obszerna i nie może być sprowadzona jedynie do start-upów. Prawie dwa lata temu zostałem częścią zespołu sporej firmy zajmującej się outsourcingiem na potrzeby klientów różnej wielkości.

Rozmawiając z kolegami w kuchni odkryłem, że większość z nich potwierdza, że testy jednostkowe i TDD to rodzaj najlepszej praktyki. Ale we wszystkich projektach w których brałem udział nie było testów. I nie, nie była to moja decyzja. Oczywiście wiem, że istnieją projekty oparte na rozbudowanym testowaniu, także w mojej firmie. Ale ten rodzaj projektów wiąże się także z ogromną ilością biurokracji.

W czym tak naprawdę tkwi problem? Dlaczego każdy zgadza się, że TDD jest w porządku, ale właściwie nikt go nie używa?

Czy TDD jest złe? – Nie!
A może jest bezwartościowe z biznesowego punktu widzenia? – I znów – nie!
Czy programiści są leniwi? – Tak, ale to nie jest powód.
Problemem są testy same w sobie.

Tak, brzmi to dziwnie, ale postaram się to udowodnić.

Problemem są testy!

Zgodnie z tym badaniem Narzędzia do testowania miały najmniejszy wskaźnik zadowolenia w latach 2016 i 2017. Nie znalazłem wcześniejszych danych na ten temat, ale to nie ma znaczenia.

Odrobina historii

W 2008 został wypuszczony jeden z pierwszych frameworków do testowania (QUnit).
W 2010 pojawił się Jasmine.
W 2011 – Mocha.
Pierwszy release Jest, który znalazłem pochodzi z 2014.

Dla porównania

W 2010 pojawił się angular.js.
Ember wprowadzono w 2011.
React pochodzi z 2013.
I tak dalej…

Żadne frameworki JS nie zostały stworzone w trakcie przygotowania tego tematu. Przynajmniej z moim udziałem. W tym samym czasie swoje wzloty i upadki zaliczyły grunt, potem gulp. Potem zdaliśmy sobie sprawę z potencjału npm scripts i pojawił się webpack.

W ciągu ostatnich 10 lat zmieniło się wszystko, oprócz testowania.

Mały quiz

Sprawdźmy twoją wiedzę. Jaka to biblioteka/framework?

  1.       
            var hiddenBox = $("#banner-message");
              $("#button-container button").on("click", function(event) {
                  hiddenBox.show();
              });
          
        
  2.       
            @Component({
                selector: 'app-heroes',
                templateUrl: './heroes.component.html',
                styleUrls: ['./heroes.component.css']
            })
            export class HeroesComponent{
                hero: Hero = {
                    id: 1,
                    name: 'Windstorm'
                };
    
                constructor() { }
            }
          
        
  3.       
            function Avatar(props) {
                return (
                    <img className="Avatar"
                         src={props.user.avatarUrl}
                         alt={props.user.name}
                    />
                );
            }
          
        

Odpowiedzi:

  1. JQuery
  2. Angular2+
  3. React

Ok, super. Jestem pewien, że odpowiedzieliście poprawnie na wszystkie pytania. A jak wygląda sytuacja z frameworkami do testów?

  1.       
            var assert = require('assert');
            describe('Array', function() {
              describe('#indexOf()', function() {
                it('should return -1 when the value is not present', function() {
                  assert.equal([1,2,3].indexOf(4), -1);
                });
              });
            });
          
        
  2.       
            const sum = require('./sum');
    
            test('adds 1 + 2 to equal 3', () => {
              expect(sum(1, 2)).toBe(3);
            });
          
        
  3.       
            test('timing test', function (t) {
                t.plan(2);
    
                t.equal(typeof Date.now, 'function');
                var start = Date.now();
    
                setTimeout(function () {
                    t.equal(Date.now() - start, 100);
                }, 100);
            });
          
        
  4.       
            let When2IsAddedTo2Expect4 = 
                Assert.AreEqual(4, 2+2)
          
        

Odpowiedzi:

  1. Mocha
  2. Jest
  3. Tape
  4. Test for F#

Mogliście zgadnąć część odpowiedzi, ale – generalnie – te narzędzia są bardzo podobne. Zauważcie, że nawet w różnych językach większość rzeczy wygląda właściwie tak samo.

Mamy więc przynajmniej 8 lat doświadczenia w testach jednostkowych w świecie JavaScriptowym. Ale część z nich po prostu zaadaptowaliśmy. Testy jednostkowe, jakie znamy pojawiły się zdecydowanie wcześniej. Jeśli uznamy pojawienie się Test Anything Protocol (1987) za nasz punkt startowy, okazuje się, że stosujemy obecne podejście dłużej niż mam przyjemność być na tym świecie.

TDD – przegląd

Pozwólcie mi przypomnieć, czym jest TDD, żebyśmy mogli wystartować z tego samego punktu. Przytoczę definicję z anglojęzycznej Wikipedii:

Test-driven development (TDD) is a software development process that relies on the repetition of a very short development cycle: requirements are turned into very specific test cases, then the software is improved to pass the new tests, only. © wikipedia

Ale jakie korzyści płyną z jego stosowania?

Testy to sformalizowane wymagania

To prawda tylko po części.

TDD zostało odkryte ponownie przez Kenta Becka w 1999 roku, a Agile Manifesto został zaakceptowany 2 lata później ( w 2001). Musiałem o tym wspomnieć, by zwrócić uwagę na fakt, że TDD narodziło się w „Złotym Wieku” modelu kaskadowego i właśnie ten fakt determinuje okoliczności i procesy, dla których został zaprojektowany.

Jeśli więc pracujesz w projekcie, w którym:

  1. Wymagania są jasne.
  2. Rozumiesz je całkowicie.
  3. Są stabilne i nie będą ulegać częstym zmianą.

Możesz tworzyć testy, które będą funkcjonowały jak sformalizowane wymagania. Ale by traktować istniejące testy w ten sam sposób następujące stwierdzenia również powinny być prawdziwe:

  1. Testy nie mają bugów.
  2. Są aktualne.
  3. Pokrywają prawie wszystkie przypadki użycia (nie mylić z pokryciem kodu!)

Stwierdzenie „testy to sformalizowane wymagania” jest więc prawdziwe tylko w przypadku, w którym te wymagania istnieją, zanim rozpocznie się proces wytwarzania oprogramowania. Tak, jak w modelu kaskadowym, w którym „klientami” są naukowcy/inżynierowie.

W niektórych okolicznością ma to szanse działać również w procesie Agile. Szczególnie kiedy stosowane jest BDD, ale to zupełnie inna historia.

TDD wymusza dobrą architekturę

Znów – to prawda tylko po części.

TDD wymusza modularność, która jest potrzebna, ale nie niezbędna, gdy mówimy o dobrej architekturze.

Odpowiedzialność za jakość architektury spoczywa na programistach. Doświadczeni specjaliści są w stanie tworzyć wysokiej jakości kod niezależnie od tego, czy używa się testów jednostkowych. Z drugiej strony, średnio wykwalifikowani specjaliści będą produkować kod słabej jakości pokrywany przez testy słabej jakości, ponieważ tworzenie dobrych testów to sztuka, tak jak samo programowanie.

Tak, testy są jak seks „lepsze słabe niż żadne”. Ale…

Taki test nie przybliża cię do dobrze zaprojektowanego systemu:

  
    import { inject, TestBed } from '@angular/core/testing';

    import { UploaderService } from './uploader.service';

    describe('UploaderService', () => {
        beforeEach(() => {
            TestBed.configureTestingModule({
                providers: [UploaderService],
            });
        });

        it('should be created', inject([UploaderService], (service: UploaderService) => {
            expect(service).toBeTruthy();
        }));
    });
  

Bo właściwie niczego nie testuje.

Użyliśmy 15 linijek kodu, by niczego nie przetestować.

Ale kolejny test również nie wpłynie dobrze na architekturę:

  
    var IotSimulation = artifacts.require("./IotSimulation.sol");
    var SmartAsset = artifacts.require("./SmartAsset.sol");
    var BuySmartAsset = artifacts.require("./BuySmartAsset.sol");

    var BigInt = require('big-integer');

    contract('BuySmartAsset', function (accounts) {

        it("Should sell asset", async () => {
            var deliveryCity = "Lublin";

            var extra = 1000; //
            var gasPrice = 100000000000;

            const smartAsset = await SmartAsset.deployed();
            const iotSimulation = await IotSimulation.deployed();
            const buySmartAsset = await BuySmartAsset.deployed()

            const result = await smartAsset.createAsset(Date.now(), 200, "docUrl", 1, "email@email1.com", "Audi A8", "VIN02", "black", "2500", "car");
            const smartAssetGeneratedId = result.logs[0].args.id.c[0];

            await iotSimulation.generateIotOutput(smartAssetGeneratedId, 0);
            await iotSimulation.generateIotAvailability(smartAssetGeneratedId, true);
            await smartAsset.calculateAssetPrice(smartAssetGeneratedId);

            const assetObjPrice = await smartAsset.getSmartAssetPrice(smartAssetGeneratedId);
            assert.isAbove(parseInt(assetObjPrice), 0, 'price should be bigger than 0');

            await smartAsset.makeOnSale(smartAssetGeneratedId);

            var assetObj = await smartAsset.getAssetById.call(smartAssetGeneratedId);
            assert.equal(assetObj[9], 3, 'state should be OnSale = position 3 in State enum list');

            await smartAsset.makeOffSale(smartAssetGeneratedId);
            assetObj = await smartAsset.getAssetById.call(smartAssetGeneratedId);
            assert.equal(assetObj[9], 2, 'state should be PriceCalculated = position 2 in State enum list');

            await smartAsset.makeOnSale(smartAssetGeneratedId);

            const calculatedTotalPrice = await buySmartAsset.getTotalPrice.call(smartAssetGeneratedId, '112', '223');
            await buySmartAsset.buyAsset(smartAssetGeneratedId, '112', '223', { from: accounts[1], value: BigInt(calculatedTotalPrice.toString()).add(BigInt(extra)) });

            assetObj = await smartAsset.getAssetById.call(smartAssetGeneratedId);
            assert.equal(assetObj[9], 0, 'state should be ManualDataAreEntered = position 0 in State enum list');
            assert.equal(assetObj[10], accounts[1]);

            const balanceBeforeWithdrawal = await web3.eth.getBalance(accounts[1]);
            const gas = await buySmartAsset.withdrawPayments.estimateGas({ from: accounts[1] });
            await buySmartAsset.withdrawPayments({ from: accounts[1], gasPrice: gasPrice });

            const balanceAfterWithdrawal = await web3.eth.getBalance(accounts[1]);

            var totalGas = gas * gasPrice;

            assert.isOk((BigInt(balanceAfterWithdrawal.toString()).add(BigInt(totalGas))).eq(BigInt(balanceBeforeWithdrawal.toString()).add(BigInt(extra))));
        })
    })
  

Największym problemem tego testu jest brak początkowej bazy kodu, ale mógłby być w dużym stopniu ulepszony, nawet bez refaktoryzacji istniejącego projektu.

Wpływ TDD na architekturę jest właściwie prawie taki sam, jak wpływ wyboru frameworku/biblioteki, jeśli nie mniejszy (np. Nest, RxJs and MobX mają, moim zdaniem, o wiele większy wpływ niż TDD)

Ale ani TDD ani frameworki nie powstrzymają nikogo przed tworzeniem kod słabej jakości i podejmowania złych decyzji na etapie projektowania. Nie istnieje przeciw temu żaden cudowny środek.

TDD oszczędza czas

Ok, to zależy.

Przypuśćmy, że:

  1. Każdy w projekcie jest wystarczająco pewny używając wybranego frameworku do testowania, metodologii TDD i najlepszych praktyk testowania jednostkowego.
  2. I nie ma żadnych nieporozumień.
  3. I wymagania są jasne i stabilne.
  4. I managerowie chętnie rozwiązują wszystkie problemy organizacyjne, które z tego wynikają (np. dłuższy onboarding nowych specjalistów).

Nawet w tak idealnej sytuacji musimy zainwestować w cały proces sporo wysiłku, co wydłuży początkową fazę rozwoju oprogramowani, a różnicę odczujemy dopiero później – skrócony czas naprawiania błędów i utrzymania. Jasne, że ta druga rzecz jest ważniejsza niż wymieniona wada. W tym przypadku zaobserwujemy korzyści płynące z TDD. W niektórych przypadkach zaoszczędzisz czas również przy implementacji nowych funkcji, ponieważ testy pokażą, w których miejscach pojawią się problemy.

Można nawet wpaść w taki cykl:

Ok, ten cykl nie do końca oddaje ducha TDD, ten robi to lepiej:

Spróbuj znaleźć istotne różnice.

Testy to najlepsza dokumentacja.

Nie. Jest ok, ale nie jest najlepsza. Popatrzmy na dokumentację w Angularze:

Albo w React:

Co waszym zdaniem mają wspólnego? Obie zbudowane są wokół przykładowego kodu. I nawet więcej – wszystkie te przykłady są działające (Angular używa StackBlitz, a React – CodePen), jesteśmy więc w stanie zobaczyć, co się stanie, kiedy coś zmienimy. Mają oczywiście również jakąś zawartość tekstu, ale to jak komentarze w kodzie – potrzebujesz ich po to, by zrozumieć, na czym kod się opiera.

Testy są temu bliskie, ale nie ma tu działających próbek kodu:

  
    describe('ReactTypeScriptClass', function() {
    beforeEach(function() {
      container = document.createElement('div');
      attachedListener = null;
      renderedName = null;
    });

    it('preserves the name of the class for use in error messages', function() {
      expect(Empty.name).toBe('Empty');
    });

    it('throws if no render function is defined', function() {
      expect(() =>
        expect(() =>
          ReactDOM.render(React.createElement(Empty), container)
        ).toThrow()
      ).toWarnDev([
        // A failed component renders twice in DEV
        'Warning: Empty(...): No `render` method found on the returned ' +
          'component instance: you may have forgotten to define `render`.',
        'Warning: Empty(...): No `render` method found on the returned ' +
          'component instance: you may have forgotten to define `render`.',
      ]);
    });
  

To niewielka część rzeczywistych testów w React. Można w nich wydzielić przykłady kodu.

  
    container = document.createElement('div');
    Empty.name;
  
  
    container = document.createElement('div');
    ReactDOM.render(React.createElement(Empty), container);
  

Wszystko inne to ręcznie stworzona infrastruktura do testowania.

Bądźmy szczerzy, poprzednia próbka testu jest zdecydowanie mniej czytelna niż prawdziwa dokumentacja. I problem nie leży w tym konkretnym teście – wierzę w to, że team Facebooka wie, jak pisać dobry kod i dobre testy :) Wszystko to - od narzędzi do testowania po biblioteki asercji, jak: it, describe, test, to.be.true - rozsadza twoje zestawy testów.

Istnieje biblioteka o nazwie tape z minimalnym API, a każdy test może być przepisany z użyciem tylko equal/deepEqual i taki sposób myślenia jest – generalnie – korzystny z punktu widzenia testów jednostkowych. Ale nawet testy w tape są dalekie od bycia wykonywalnymi próbkami kodu.

Muszę przyznać, że jest to jednak użyteczne, jako dokumentacja. Jest mniejsze prawdopodobieństwo, że się przeterminuje, a z drugiej strony – umysł wyrzuca z siebie wszystko, co nie jest istotne, kiedy przeglądamy testy. Kiedy spróbujemy zwizualizować własne myśli w tym momencie, dostaniemy coś w tym stylu:

Jak widzicie, jest to bliższe prawdziwej dokumentacji niż początkowemu testowi.

Pośrednie wnioski

  1. Testy to sformalizowane wymagania, jeśli są stabilne;
  2. TDD wymusza dobrą architekturę, jeśli developerzy są wystarczająco wykwalifikowani;
  3. TDD oszczędza czas, jeśli początkowo go zainwestujesz;
  4. Testy to najlepsza dokumentacja, jeśli nie ma innych wykonywalnych próbek kodu.

To co, TDD jest złe, czyż nie? – Nie, nie jest.

Pokazuje właściwy kierunek i podejmuje istotne pytania. Powinniśmy jedynie przemyśleć i zmienić sposób, w jaki je aplikujemy.

Jakie jest rozwiązanie?

Nie traktuj TDD jak panaceum na wszystkie bolączki. Nie traktuj go także jako procesu na tym samym poziomie co, powiedzmy, Agile.

Zamiast tego skup się na mocnych stronach:

  1. Zapobieganiu nieumyślnym zmianom, innymi słowy – zamrażaniu obecnego zachowania, jako swojego rodzaju baseline.
  2. Używaniu próbek dokumentacji jako testów.

Pomyślcie o testowaniu jednostkowym, jako o narzędziu dla developera. Jak linter albo compiler.

Na przykład – nie pytaj Product Ownera o użycie lintera, zamiast tego po prostu go użyj.

Pewnego dnia, będzie to prawdą również w przypadku testów jednostkowych. Kiedy wysiłki związane z TDD będą na poziomie tych związanych z użytkowaniem typecheckera albo bundlera. Ale do tego czasu minimalizujcie wysiłek, tworząc testy tak bliskie wykonywalnym próbkom jak to możliwe i używajcie ich jako aktualnego baseline dotyczącego stanu projektu.

Oczywiście rozumiem, że będzie to trudne, zwłaszcza w momencie, kiedy większość narzędzi nie jest zaprojektowanych w sposób odpowiadający temu podejściu.

Właściwie stworzyłem takie rozwiązanie, biorąc pod uwagę wszystkie wymienione problem. Nazywa się BaseT.

Koncepcja Base jest prosta. Wpisz swój kod:

  
    export function sampleFn(a: any, b: any) {
        return a + b + b + a;
    }
  

I użyj go w pliku testowym:

  
    import { sampleFn } from './index';

    export = {
        values: [
            sampleFn(1, 1),
            sampleFn(1000000, 1000000),
            sampleFn('abc', 'cba'),
            sampleFn(1, 'abc'),
            sampleFn('abc', 1),
            new Promise(resolve => resolve(sampleFn('async value', 1))),
        ],
    };
  

UWAGA: Test jest bardzo wąski, do celów demonstracyjnych.

Następnie odpal test BaseT, by otrzymać tymczasowy baseline:

  
    {
        "values": [
            4,
            4000000,
            "abccbacbaabc",
            "1abcabc1",
            "abc11abc",
            "async value11async value"
        ]
    }
  

Jeśli wartości są poprawne, odpal BaseT i zakomituj stworzony baseline do swojego repozytorium.

Wszystkie kolejne próbne uruchomienia będą porównywały istniejący baseline z wartościami eksportowanymi z twoich plików testowych. Jeśli się różnią, wtedy test nie przejdzie – w przeciwnym wypadku wynik testu będzie pomyślny. Jeśli zmienią się wymagania, zmień kod i zaakceptuj nowy baseline.

To narzędzie nadal zapobiega wszelkim niechcianym zmianom, jednocześnie minimalizując twoje wysyłki. I wszystko, czego potrzebujesz to napisane przez ciebie wykonywalne próbki kodu, które są podstawą dobrej dokumentacji

Przykłady

Możesz używać go z Reactem. Ten test:

  
  import * as React from 'react';
  import { jsxFn } from './index';

  export const value = (
      <div>
          {jsxFn('s', 's')}
          {jsxFn('abc', 'cba')}
          {jsxFn('s', 'abc')}
          {jsxFn('abc', 's')}
      </div>
  );
  

wygeneruje następujący plik jako baseline:

exports.value:

  
    <div data-reactroot="">
        <div class="cssCalss">
            ss
        </div>
        <div class="cssCalss">
            abccba
        </div>
        <div class="cssCalss">
            sabc
        </div>
        <div class="cssCalss">
            abcs
        </div>
    </div>
  

Albo z pixi.js:

  
    import 'pixi.js';
    interface IResourceDictionary {
        [index: string]: PIXI.loaders.Resource;
    }

    const ASSETS = './assets/assets.json';
    const RADAR_GREEN = 'Light_green';

    const getSprite = async () => {
        await new Promise(resolve => PIXI.loader
            .add(ASSETS)
            .load(resolve));

        return new PIXI.Sprite(PIXI.utils.TextureCache[RADAR_GREEN]);
    };

    export const sprite = getSprite();
  

Test wygeneruje następujący plik jako baseline:

exports.sprite:

Plany

Muszę wspomnieć, że to narzędzie jest we wczesnej fazie beta i czeka na nie wiele planów, jak:

  1. Watch/Workflow mode
  2. TAP compatibility
  3. Git acceptance strategy
  4. VS Code extension
  5. … i przynajmniej 24 inne.

W tym momencie zaimplementowanych jest jedynie około 40% funkcji pierwszej stabilnej wersji. Ale kluczowe elementy działają, więc możecie się nimi pobawić. A nawet polubić. Kto wie?