Refactoring i testy legacy encji z logiką w konstruktorach

0

Witam wszystkich,

trafiłem do projektu legacy i chciałbym zrefaktorować klasę mającą 800 LoC. Problem jest taki, że nie wiem z jakiej strony podejść do napisania testów do tego potworka :)

Zależności które wchodzą do konstruktora to inne encje, które również wyglądają bardzo podobnie, tj. kilkaset LoC + zagnieżdżone inne encje. Klasa ma dwie metody publiczne, a cała logika jest schowana w prywatnych metodach wołanych z poziomu konstruktorów!

Definicje klas wymaganych przez konstruktor są tak skomplikowane, że nie znalazłem zadowalającego sposobu na przygotowanie setupu dla testu. Nawet gdyby się to udało, to i tak pozostaje multum ścieżek do pokrycia. Dodam, że jest to kluczowa klasa, wykorzystywana w ponad 150 miejscach w solucji.

public class IndexResult
{
    private IReadOnlyCollection<PackageCalculationType> _packageCalculationTypes;
    private PackageCalculationType _primaryCalculationType = PackageCalculationType.MainValuationMode;
    public IndexDefinition IndexDefinition { get; private set; }
    public CalculationResultPortfolio Portfolio { get; private set; }
    public IReadOnlyCollection<Group> Groups { get; private set; }
    public bool IsError { get; private set; }
    public string ErrorShortDescription { get; private set; }
    public string ErrorDetailedDescription { get; private set; }
    public IndexError ErrorCode { get; private set; }
    public bool IsForDependentIndex => IndexDefinition.IsConsolidated;
    public bool NoPositionFound { get; private set; }
    public bool IsActivatedByDependency { get; private set; }
    public bool IsDeactivatedByDependency { get; private set; }
    public Nominator Nominator { get; private set; }
    

    public IndexResult(IndexDefinition indexDefinition, 
                        IndexPortfolio indexPortfolio, 
                        List<CalculatedGroup> calculatedGroups, 
                        Nominator Nominator,
                        IndexPositionType positionType,
                        HashSet<PortfolioWarning> portfolioWarnings,
                        IReadOnlyCollection<PackageCalculationType> packageCalculationTypes,
                        PolicyValueSet policyValueSet,
                        CalculationContext calculationContext = CalculationContext.None,
                        CalculationSummary previousCalculationSummary = null,
                        IndexModelPortfolio modelPortfolio = null)
    {
        IndexDefinition = indexDefinition;
        PortfolioWarnings = portfolioWarnings;
        ModelPortfolio = modelPortfolio;
        Portfolio = new CalculationResultPortfolio(indexPortfolio);
        Nominator = Nominator;
        PositionType = positionType;
        SetPackageCalculationTypes(packageCalculationTypes);
        CheckCalculationError();
        Groups = CreateGroups(calculatedGroups, policyValueSet, previousCalculationSummary, calculationContext);
        SetIndexLevelValue();
        Validate();
    }
  
  
/*

... private methods

*/

    public void SetActiveByDependency()
    {
        IsActivatedByDependency = true;
        SetIndexLevelValue();
    }
    
    public void SetDeactivateByDependency()
    {
        IsDeactivatedByDependency = true;
        SetIndexLevelValue();
    }
  
}

pełny listening kodu - https://pastebin.com/dyeejsex

Zastanawiałem się nad utworzeniem bezparametrowego konstruktora internal i zmianą modyfikatorów dostępu metod z private na protected. Następnie utworzyłbym klasę IndexResultTest która by dziedziczyła po IndexResult i wołałbym metody prywatne z jej poziomu, coś w stylu

public class IndexResultTest: IndexResult
{
    public FooTest(string parameter) => Foo(parameter); //private method
}

Oczywiście, że wolałbym testować tylko publiczne api i nie zmieniać kodu produkcyjnego na potrzeby testów. Czy jednak w tej sytuacji mam inne wyjście niż testowanie prywatnych metod i krok po kroczku wydzielanie logiki do osobnych klas?

W jaki sposób Wy byście podeszli do tematu? Będę wdzięczny za każdą odpowiedź.
W szczególności liczę na podpowiedzi od @jarekr000000 @somekind @Shalom @Charles_Ray @neves @Afish

3
Jeffdot napisał(a):

Oczywiście, że wolałbym testować tylko publiczne api

Na to przyjdzie czas później.

Jeffdot napisał(a):

nie zmieniać kodu produkcyjnego na potrzeby testów

Tak się nie da, kod produkcyjny musi być napisany pod testy. Skoro teraz się go nie da testować, to będziesz musiał go zmienić, jedynie na początku trzeba pilnować, żeby nie zrobić tego za bardzo.

Jeffdot napisał(a):

Czy jednak w tej sytuacji mam inne wyjście niż testowanie prywatnych metod i krok po kroczku wydzielanie logiki do osobnych klas?

Pewnie nie, bez kodu trudno powiedzieć. Możesz próbować uderzać w odpowiednie ścieżki przez wywoływanie publicznego API, ale to pewnie nie będzie łatwe. Poczytaj Working Effectively with Legacy Code.

Jeffdot napisał(a):

W jaki sposób Wy byście podeszli do tematu?

Ja bym się zastanowił, czy trzeba to refaktoryzować i testować. Chcesz to zrobić dla sportu, czy masz jakiś konkretny cel? Jeżeli to drugie, to ja raczej pisałbym testy tylko do tych elementów, które trzeba zmieniać w związku z trwającą pracą.

2
  1. Najpierw testy integracyjne do całości aplikacji.
  2. Potem z kodem można zrobić wszystko.
  3. Potem okaże się, że było sporo ukrytych ficzerów, więc trzeba na szybko łatać.

Ogólnie wolałbym nowy kod pisać lepiej, tak aby nie używał tej klasy i pozwolił jej kiedyś umrzeć w toku naturalnej ewolucji.

0
  1. Czy aplikacja jest pokryta testami integracyjnymi? Jeśli nie jest, to od tego bym zaczął, szczególnie jak chcesz ruszać coś co jest używane w wielu miejscach. Musisz mieć pewność, że jak odpalasz testy i są zielone, to znaczy że aplikacja nadal działa tak jak powinna, a przynajmniej przechodzi wszystkie zdefiniowane ścieżki.
  2. Jeśli faktycznie musisz to ruszać, to spróbowałbym zrobić jakis reverse-engineering tego kodu, żeby odpowiedzieć sobie na pytanie co ten kod robi? ale w sensie jaki efekt ten kod ma osiągnąć?. Bo takie refaktorowanie na zasadzie "potnę to na mniejsze kawałki" często średnio się sprawdza. Może tak być że zamiast jednej klasy spaghetti dostaniesz 10, ale wcale nie poprawi to czytelności.
  3. Gdybym miał coś takiego modyfikować, to zacząłbym od tego, żeby kod coś zwracał. Metody void i side-effecty testuje się bardzo ciężko. Więc jeśli w ogóle coś wydzielać, to tak, żeby mieć funkcje które coś zwracają, bo takie funkcje można wygodnie testować i nas nagle nie zaskoczą.
0

Zgadzam się z poprzednikami. Raczej mała szansa, że z tej klasy uzyskasz coś dobrego - jako cel postawiłbym sobie wydzielenie kluczowych odpowiedzialności do osobnych, dobrze otestowanych klas.

Od czego zacząć? Parę testów akceptacyjnych i chyba zacząłbym od wydzielenia logiki z konstruktora do jakiejś fabryki (możesz ja nawet wołać z tego konstruktora, żeby nie ruszać klientów). Wtedy zobaczysz, czy da się w tej fabryce schować jakieś zależności klasy. Potem starałbym się ciąć per feature do dedykowanych klas. Priorytetyzuj względem obszarów, które najczęściej się zmieniają.

4

Testuj API: przecież chcesz, żeby API działało po staremu, stary kod cię nie obchodzi. Testowanie niżej oznacza tyle, że według ciebie struktura twojego kodu jest na tyle znana/dobra/utrzymywalna, że jest sens pisać testy oparte o nią. W tym wypadku tak nie jest i takie testy mogą być o dziwo szkodliwe, bo jak się za bardzo zakopiesz w zastałym kodzie to póżniej będzie strach przed refaktorem i dużymi zmianami.

Polecam to uczucie, gdy masz aplikację przetestowaną przez test API. Możesz przepisać połowę bebechów np. wprowadzenie lepszej architektury i przy zerowych zmianach w testach mieć wszystko na zielono

0

W skrócie - nie wiesz co robi ta klasa, chcesz ją porządnie przeorać i nic nie zepsuć, ale nawet jak coś zepsujesz, to nie będziesz wiedział co. W rezultacie masz kod, którego nie da się pokryć testami, bo trzeba najpierw zrobić refaktor, a nie możesz zrobić refaktoru, bo strach dotknąć coś, co nie ma testów.
Standardowe podejście to:

  • Piszesz testy funkcjonalne, przy okazji rozgryzając co i dlaczego robi ta klasa. Czy to ma być test z poziomu np. REST API, czy UI zależy co masz w aplikacji.
  • zabierasz się za "zgrubny" refaktor sprawdzając czy aplikacja wciąż robi, to co ma robić
  • do świeżych jednostek kodu dopisujesz testy na poziomie api pakietu/modułu
  • jak już masz odrobinę ogarnięte testy, to robisz właściwy refaktor

Roboty w cholerę, ale plus taki, że przy okazji można pokryć automatami sporą część całego rozwiązania.

1 użytkowników online, w tym zalogowanych: 0, gości: 1