Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Unit Testing im Brownfield? Challenge accepted!

Unit Testing im Brownfield? Challenge accepted!

Unit Testing ist mittlerweile in fast jedem Entwicklerkopf angekommen. Das ist gut so.

Die Grundlagen sind leicht erlernt, ein geeignetes Testing Framework schnell gefunden und die Einarbeitung dank oftmals guter Dokumentation auch kein Hexenwerk.
So gewappnet meistert man das eine oder andere Kata mit Bravour und in Greenfield Projekten kann man sein Wissen voll auskosten und so eine ausreichend gute Test Coverage erreichen.

Doch wie sieht das Ganze in gewachsenen Systemen aus? Dort wo die Logik weit im Quellcode verstreut und schwer zu finden ist? Wie erreicht man dort eine ausreichend gute Testabdeckung? Und was zum Henker ist in einem historisch oder gar hysterisch gewachsenen System überhaupt alles zu testen?

In dieser Session zeige ich, wie einige der SOLID Prinzipien, gepaart mit der Onion Architecture und Building Blocks aus dem DDD Universum helfen können, ein gewachsenes Tohuwabohu schrittweise in ein gut testbares System umzubauen.

Andreas Richter

September 24, 2020
Tweet

More Decks by Andreas Richter

Other Decks in Programming

Transcript

  1. Warum automatisiert testen? Funktionalität durch automatisierte Tests absichern Ständige Überprüfung

    gegenüber Spezi kationen Sicherheitsnetz aufbauen für Erweiterungen für Restrukturierungen
  2. Warum sind gewachsene Systeme schwer testbar? Code über mehrere Jahre

    gewachsen Code über mehrere Entwicklergenerationen gewachsen Viel Code unter Zeit- und Budgetdruck entsanden Bekannte Patterns nicht eingehalten Resultat: Historisch Hysterisch gewachsenes System Unstrukturierter Code Fragmentierte Domänenlogik im Code
  3. Beispiel Code direkt in Button Click Methode (Code behind) Direkte

    Abhängigkeiten (new ApiClient, Messagebox.Show) private async void ButtonDelete_Click(object sender, RoutedEventArgs e) { if (MessageBox.Show("Soll die Session wirklich gelöscht werden?", "Bestätigen", MessageBoxButton.YesNo) == MessageBoxResult.Yes) { if (int.TryParse(TextBoxId.Text, out var id)) { var apiClient = new ApiClient(); var deleted = await apiClient.DeleteSession(id); MessageBox.Show(deleted ? "Session erfolgreich gelöscht." : "Session konnte nicht gelöscht werden.", "Info"); NewSession(); await LoadSessions(); } } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 private async void ButtonDelete_Click(object sender, RoutedEventArgs e) 1 { 2 if (MessageBox.Show("Soll die Session wirklich gelöscht werden?", 3 "Bestätigen", MessageBoxButton.YesNo) == MessageBoxResult.Yes) 4 { 5 if (int.TryParse(TextBoxId.Text, out var id)) 6 { 7 var apiClient = new ApiClient(); 8 var deleted = await apiClient.DeleteSession(id); 9 MessageBox.Show(deleted ? 10 "Session erfolgreich gelöscht." : 11 "Session konnte nicht gelöscht werden.", "Info"); 12 NewSession(); 13 await LoadSessions(); 14 } 15 } 16 } 17 var apiClient = new ApiClient(); MessageBox.Show(deleted ? "Session erfolgreich gelöscht." : "Session konnte nicht gelöscht werden.", "Info"); private async void ButtonDelete_Click(object sender, RoutedEventArgs e) 1 { 2 if (MessageBox.Show("Soll die Session wirklich gelöscht werden?", 3 "Bestätigen", MessageBoxButton.YesNo) == MessageBoxResult.Yes) 4 { 5 if (int.TryParse(TextBoxId.Text, out var id)) 6 { 7 8 var deleted = await apiClient.DeleteSession(id); 9 10 11 12 NewSession(); 13 await LoadSessions(); 14 } 15 } 16 } 17
  4. Beispiel Validierungen im Code Behind private async void ButtonSave_Click(object sender,

    RoutedEventArgs e) { if (string.IsNullOrEmpty(TextBoxTitle.Text)) { MessageBox.Show("Es muss ein Titel angegeben werden."); return; } if (string.IsNullOrEmpty(TextBoxAbstract.Text)) { MessageBox.Show("Es muss ein Abstract angegeben werden."); return; } ... var saved = await apiClient.Save(session); ... } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 if (string.IsNullOrEmpty(TextBoxTitle.Text)) { MessageBox.Show("Es muss ein Titel angegeben werden."); return; } if (string.IsNullOrEmpty(TextBoxAbstract.Text)) { MessageBox.Show("Es muss ein Abstract angegeben werden."); return; } private async void ButtonSave_Click(object sender, RoutedEventArgs e) 1 { 2 3 4 5 6 7 8 9 10 11 12 13 14 ... 15 var saved = await apiClient.Save(session); 16 ... 17 } 18
  5. Beispiel Eingabedaten in Controls private async void ButtonSave_Click(object sender, RoutedEventArgs

    e) { ... if (int.TryParse(TextBoxId.Text, out var id)) { var session = new Session { Id = id, Title = TextBoxTitle.Text, Abstract = TextBoxAbstract.Text }; var apiClient = new ApiClient(); var saved = await apiClient.Save(session); MessageBox.Show(saved ? "Session erfolgreich gespeichert." : "Session konnte nicht gespeichert werden.", "Info"); await LoadSessions(); } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 if (int.TryParse(TextBoxId.Text, out var id)) { var session = new Session { Id = id, Title = TextBoxTitle.Text, Abstract = TextBoxAbstract.Text }; private async void ButtonSave_Click(object sender, RoutedEventArgs e) 1 { 2 ... 3 4 5 6 7 8 9 10 11 12 var apiClient = new ApiClient(); 13 var saved = await apiClient.Save(session); 14 MessageBox.Show(saved ? 15 "Session erfolgreich gespeichert." : 16 "Session konnte nicht gespeichert werden.", "Info"); 17 await LoadSessions(); 18 } 19 } 20
  6. Beispiel DB-Aufruf aus API Controller heraus [HttpPost] public async Task<ActionResult<Session>>

    PostSession(Session session) { var context = new ConferenceContext(); context.Sessions.Add(session); await context.SaveChangesAsync(); return CreatedAtAction("GetSession", new { id = session.Id }, session); } 1 2 3 4 5 6 7 8 9 var context = new ConferenceContext(); context.Sessions.Add(session); await context.SaveChangesAsync(); [HttpPost] 1 public async Task<ActionResult<Session>> PostSession(Session session) 2 { 3 4 5 6 7 return CreatedAtAction("GetSession", new { id = session.Id }, session); 8 } 9
  7. Fachliche Module erkennen Conferene Dude SQLite Datenbank ASP.NET Web API

    Session Controller Speaker Controller Room Conroller Conference Db Context WPF Desktop Client Session Window Speaker Window Room Window ApiClient
  8. Codeverteilung pro Modul erforschen Sessions SQLite Datenbank * Lädt alle

    Sessions von der WebAPI * Erstellt neue Session * Validiert vorm Speichern auf * Vorhandensein vom Titel * Vorhandensein vom Abstract * ... * Unterscheidet beim Speichern anhand der Id zwischen Create und Update * ... WPF Desktop Client Session Window ApiClient ASP.NET Web API Session Controller Conference Db Context * Stellt API Endpunkte für CRUD bereit * Reicht Aufrufe über ConferenceContext an DB weiter * ... * Implementiert Zugriff auf DB mit Entity Framework * Stellt per Unique Constraint Eindeutigkeit vom Session Titel sicher
  9. Domänenlogik pro Modul extrahieren Sessions SQLite Datenbank * Lädt alle

    Sessions von der WebAPI * Erstellt neue Session * Validiert vorm Speichern auf * Vorhandensein vom Titel * Vorhandensein vom Abstract * ... * Unterscheidet beim Speichern anhand der Id zwischen Create und Update * ... WPF Desktop Client Session Window ApiClient ASP.NET Web API Session Controller Conference Db Context * Stellt API Endpunkte für CRUD bereit * Reicht Aufrufe über ConferenceContext an DB weiter * ... * Implementiert Zugriff auf DB mit Entity Framework * Stellt per Unique Constraint Eindeutigkeit vom Session Titel sicher
  10. Was muss überhaupt getestet werden? 1. Domänenlogik - Unit Tests

    2. Zusammenarbeit von Client & Server mit Domänenlogik - Integrationstests 3. Integration von der UI / API bis zur Datenbank - Systemtests 4. Bedienbarkeit vom UI - UI Tests (automatisiert / teilautomatisiert) 5. Optik - UI Tests (manuell / teilautomatisiert) UI System Integration Unit Ausführungszeit Aufwand / Kosten
  11. Sprout Method Green eld Spross im Brown eld Sumpf Neue

    Funktionalität testgetrieben entwickeln Alter Code nutzt neuen Spross Nicht umgekehrt!
  12. Sprout - Domain Model public class Session { public int

    Id { get; set; } public string Title { get; set; } public string Abstract { get; set; } public ValidationResult Validate() { var validationResult = new ValidationResult(); if (string.IsNullOrEmpty(Title)) validationResult.AddError(nameof(Title), "Das Feld ist ein Pflichtfeld") if (string.IsNullOrEmpty(Abstract)) validationResult.AddError(nameof(Abstract), "Das Feld ist ein Pflichtfel return validationResult; } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
  13. Sprout - Verwendung im Client vorher private async void ButtonSave_Click(object

    sender, RoutedEventArgs e) 1 { 2 if (string.IsNullOrEmpty(TextBoxTitle.Text)) 3 { 4 MessageBox.Show("Es muss ein Titel angegeben werden."); 5 return; 6 } 7 8 if (string.IsNullOrEmpty(TextBoxAbstract.Text)) 9 { 10 MessageBox.Show("Es muss ein Abstract angegeben werden."); 11 return; 12 } 13 ... 14 } 15 if (string.IsNullOrEmpty(TextBoxTitle.Text)) { MessageBox.Show("Es muss ein Titel angegeben werden."); return; } if (string.IsNullOrEmpty(TextBoxAbstract.Text)) { MessageBox.Show("Es muss ein Abstract angegeben werden."); return; } private async void ButtonSave_Click(object sender, RoutedEventArgs e) 1 { 2 3 4 5 6 7 8 9 10 11 12 13 ... 14 } 15
  14. Sprout - Verwendung im Client nachher private async void ButtonSave_Click(object

    sender, RoutedEventArgs e) 1 { 2 if (int.TryParse(TextBoxId.Text, out var id)) 3 { 4 var sessionModel = new SessionModel 5 { 6 Id = id, 7 Title = TextBoxTitle.Text, 8 Abstract = TextBoxAbstract.Text 9 }; 10 11 var validationResult = sessionModel.ToSession().Validate(); 12 if (!validationResult.Success) 13 { 14 foreach (var message in validationResult.Messages) 15 { 16 MessageBox.Show( 17 $"Feld {message.FieldName} - {message.ErrorMessage}"); 18 } 19 20 return; 21 } 22 var validationResult = sessionModel.ToSession().Validate(); if (!validationResult.Success) { foreach (var message in validationResult.Messages) { MessageBox.Show( $"Feld {message.FieldName} - {message.ErrorMessage}"); } private async void ButtonSave_Click(object sender, RoutedEventArgs e) 1 { 2 if (int.TryParse(TextBoxId.Text, out var id)) 3 { 4 var sessionModel = new SessionModel 5 { 6 Id = id, 7 Title = TextBoxTitle.Text, 8 Abstract = TextBoxAbstract.Text 9 }; 10 11 12 13 14 15 16 17 18 19 20 return; 21 } 22
  15. Sprout - Domain Service public class SessionService { ... public

    async Task<(ValidationResult validationResult, int id)> Create(Session se { var validationResult = new ValidationResult(); var createdSessionId = 0; var existingSession = await _sessionRepository.GetByTitle(session.Title); if (existingSession != null) { validationResult.AddError( nameof(Session.Title), "Eine Session mit diesem Titel ist bereits vorhanden."); } else { createdSessionId = await _sessionRepository.Create(session).ConfigureAwa } return (validationResult, createdSessionId); } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
  16. Sprout - Verwendung im Server vorher Eindeutige Titel per Unique

    Constraint in der DB! [HttpPost] public async Task<ActionResult<Session>> PostSession(Session session) 1 2 { 3 var context = new ConferenceContext(); 4 context.Sessions.Add(session); 5 await context.SaveChangesAsync(); 6 7 return CreatedAtAction("GetSession", new { id = session.Id }, session); 8 } 9 { var context = new ConferenceContext(); context.Sessions.Add(session); await context.SaveChangesAsync(); [HttpPost] 1 public async Task<ActionResult<Session>> PostSession(Session session) 2 3 4 5 6 7 return CreatedAtAction("GetSession", new { id = session.Id }, session); 8 } 9
  17. Sprout - Verwendung im Server nacher [HttpPost] public async Task<ActionResult<SessionDto>>

    PostSession(SessionDto session) 1 2 { 3 var createResult = await _sessionService.Create(session.ToSession()).ConfigureAw 4 5 if (createResult.validationResult.Success) 6 { 7 var newSession = await _sessionRepository.GetById(createResult.id).Configure 8 var newSessionDto = newSession.ToSessionDto(); 9 return CreatedAtAction("GetSession", new { id = newSessionDto.Id }, newSessi 10 } 11 12 var modelStateDictionary = new ModelStateDictionary(); 13 foreach (var message in createResult.validationResult.Messages) 14 { 15 modelStateDictionary.TryAddModelError(message.FieldName, message.ErrorMessag 16 } 17 return ValidationProblem(modelStateDictionary); 18 } 19 var createResult = await _sessionService.Create(session.ToSession()).ConfigureAw [HttpPost] 1 public async Task<ActionResult<SessionDto>> PostSession(SessionDto session) 2 { 3 4 5 if (createResult.validationResult.Success) 6 { 7 var newSession = await _sessionRepository.GetById(createResult.id).Configure 8 var newSessionDto = newSession.ToSessionDto(); 9 return CreatedAtAction("GetSession", new { id = newSessionDto.Id }, newSessi 10 } 11 12 var modelStateDictionary = new ModelStateDictionary(); 13 foreach (var message in createResult.validationResult.Messages) 14 { 15 modelStateDictionary.TryAddModelError(message.FieldName, message.ErrorMessag 16 } 17 return ValidationProblem(modelStateDictionary); 18 } 19 if (createResult.validationResult.Success) { var newSession = await _sessionRepository.GetById(createResult.id).Configure var newSessionDto = newSession.ToSessionDto(); return CreatedAtAction("GetSession", new { id = newSessionDto.Id }, newSessi } [HttpPost] 1 public async Task<ActionResult<SessionDto>> PostSession(SessionDto session) 2 { 3 var createResult = await _sessionService.Create(session.ToSession()).ConfigureAw 4 5 6 7 8 9 10 11 12 var modelStateDictionary = new ModelStateDictionary(); 13 foreach (var message in createResult.validationResult.Messages) 14 { 15 modelStateDictionary.TryAddModelError(message.FieldName, message.ErrorMessag 16 } 17 return ValidationProblem(modelStateDictionary); 18 } 19 var modelStateDictionary = new ModelStateDictionary(); foreach (var message in createResult.validationResult.Messages) { modelStateDictionary.TryAddModelError(message.FieldName, message.ErrorMessag } return ValidationProblem(modelStateDictionary); [HttpPost] 1 public async Task<ActionResult<SessionDto>> PostSession(SessionDto session) 2 { 3 var createResult = await _sessionService.Create(session.ToSession()).ConfigureAw 4 5 if (createResult.validationResult.Success) 6 { 7 var newSession = await _sessionRepository.GetById(createResult.id).Configure 8 var newSessionDto = newSession.ToSessionDto(); 9 return CreatedAtAction("GetSession", new { id = newSessionDto.Id }, newSessi 10 } 11 12 13 14 15 16 17 18 } 19
  18. Sprout - Domain Repository Interface public interface ISessionRepository { Task<IReadOnlyCollection<Session>>

    GetAll(); Task<Session> GetById(int id); Task<Session> GetByTitle(string title); Task<int> Create(Session session); } 1 2 3 4 5 6 7
  19. Sprout - Verwendung im Server vorher [HttpGet] public async Task<ActionResult<IEnumerable<Session>>>

    GetSessions() 1 2 { 3 var context = new ConferenceContext(); 4 return await context.Sessions.ToListAsync(); 5 } 6 var context = new ConferenceContext(); return await context.Sessions.ToListAsync(); [HttpGet] 1 public async Task<ActionResult<IEnumerable<Session>>> GetSessions() 2 { 3 4 5 } 6
  20. Sprout - Verwendung im Server nacher [HttpGet] public async Task<ActionResult<IEnumerable<SessionDto>>>

    GetSessions() 1 2 { 3 var sessions = await _sessionRepository.GetAll().ConfigureAwait(false); 4 var sessionDtos = sessions.Select(s => s.ToSessionDto()).ToList(); 5 return sessionDtos; 6 } 7 var sessions = await _sessionRepository.GetAll().ConfigureAwait(false); var sessionDtos = sessions.Select(s => s.ToSessionDto()).ToList(); return sessionDtos; [HttpGet] 1 public async Task<ActionResult<IEnumerable<SessionDto>>> GetSessions() 2 { 3 4 5 6 } 7
  21. Anwenden bekannter Patterns MVVWM - Model View View Model MVP

    - Model View Presenter MVC - Model View Controller DI - Dependency Injection
  22. SOLID Prinzipien anwenden Single Responsible Principle Kleinere, leichter zu testende

    Klassen Interface Segregation Principle Kleinere Interfaces, weniger Mocking Aufwand Dependency Inversion Principle Abhängigkeiten injezieren, Test in Isolation möglich
  23. Vorgehen 1. Analysieren 2. Domänenlogik extrahieren 3. Sprout mit Domänenlogik

    testgetrieben entwickeln 4. Alten Code schrittweise umstellen Verwenden vom Sprout Patterns für bessere Testbarkeit 5. Goto 1. Iterativ arbeiten Start mit fachlich einfachen Modulen
  24. Domänenlogik mit DDD Bausteinen Domänenklassen inklusive Logik auf dem Domänenobjekt

    selber Repository (Interface) De nition der Persistenz von Domänenobjekten Services Logik, die nicht in Domänenklassen passt
  25. Pattern Ports & Adapters verwenden Repository Interface in Domänenlogik de

    niert Persistenz als Domänenlogik Implementierung für Produktiv mit Zugriff auf DB per Adapter im Server Implementierung für Testfall als InMemory Repository mithilfe von Substitute / Mock per Adapter im Testcode
  26. Gibt es da noch mehr? Analyse Patterns (arc42) Improve Patterns

    (arc42) Design Patterns - Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides Working Effectively with Legacy Code - Robert C. Feathers Domain-Driven Design kompakt - Vaughn Vernon, Carola Lilienthal Langlebige Software-Architekturen - Carola Lilienthal
  27. Vielen Dank! Andreas Richter Software Craftsman & Architect  

          [email protected] @anrichter anrichter https://anrichter.net