Zunächst einmal: Test Driven Development, wozu ich insbesondere „test first“ zähle, hat für mich einen gewissen Charme. Es ist zweifelsfrei so, dass dadurch die Architektur der eigenen Anwendung verbessert wird und viele kleine Fehler vermieden werden können. Der Teufel steckt ja bekanntlich im Detail. Umso besser, wenn man nun ein wirksames Werkzeug gegen ihn hat.
Die Frage ist nur: zu welchem Preis?
Nehmen wir mal eine Situation aus meinem Alltag. Ein potentieller Kunde fragt bei mir die Erstellung einer Webapplikation an, die ein wenig vom Standard abweicht, den ich mit meinen Komponenten abdecken kann. Individualprogrammierung ist also gefragt.
Nun kann ich den Aufwand in Stunden kalkulieren, ein paar Prozent Puffer draufschlagen und das Ganze mit meinem Stundensatz multiplizieren und erhalte in etwa den Preis, den ich dem Kunden machen kann.
Üblicherweise bewegen sich die Aufwände in meinen Alltagsprojekten dann zwischen 40 und 120 Stunden. Setze ich nun strikt auf TDD, muss ich hier von Anfang an mehr Zeit einkalkulieren – trotz inzwischen fast zweijähriger Praxis würde ich diesen Mehraufwand irgendwo zwischen 10 und 30% ansetzen.
Sind wir bei 100 Stunden und einem fiktiven Stundensatz von 80 EUR also bei 8.000 EUR, lande ich mit einer realistischen Kalkulation und TDD schon bei 10.000 EUR.
Nun ist das eine schöne Milchmädchenrechnung. Was zählt ist aber die reale Welt – und in dieser konkurriere ich mit meist gleich mehreren Marktteilnehmern, deren Qualitätsansprüche an die eigene Software ich nur erahnen kann (In diesen Budgetregionen konkurriert man im Zweifel auch häufiger mit PHP-Scriptern). Und klar gibt es Ausnahmesituationen in denen es keinen Sinn für den Kunden macht wg. 2.000 EUR zu entscheiden, da sollten andere Kriterien mehr zählen – aber nicht jeder Kunde tickt so.
Dann kommt zwangsläufig die Frage auf: welchen Wert hat es für mich, auf TDD zu beharren? Oder es gar selbst zu finanzieren, also auf die 30% Aufschlag zu verzichten und es „einfach zu machen“?
Von einem gebauchpinselten Ego kann ich mir am Ende des Monats nichts kaufen. Und den wesentlich besseren Code, den ich durch TDD ohne Frage erhalte, sehe ich in 80 von 100 Fällen sowieso nie wieder, da in diesen Fällen im Lebenszyklus der Anwendung höchstens einmal kosmetische Anpassungen fällig werden – bis die App nach ein paar Jahren verrichteten Dienstes einfach weggeworfen wird. Und selbst dort, wo die Anwendung kontinuierlich weiterentwickelt wird und Refactoring an der Tagesordnung ist, kann es sein, dass der Kunde Hautausschlag bekommt, wenn auf der Rechnung dafür auch nur irgendetwas mit „Test“ draufsteht – O-Ton: „Das ist für mich selbstverständlich!“.
Und wenn man die Augen aufmacht und mal sieht was es alles so an Technologien da draußen gab, gibt und geben wird, mit denen Software entwickelt wird – da muss man sich doch wirklich fragen, ob man nicht auf einem etwas zu hohen Ross sitzt? Schließlich werden auch heute noch Anwendungen mit Access, Filemaker, und was es nicht sonsts noch so an Verbrechen gibt, erstellt, betreut und von den Kunden begeistert oder wenigstens zufrieden verwendet. Obwohl es jedem von uns vermutlich den Magen umdreht, wenn man mal unter die Haube schaut.
Und nicht zuletzt muss man auch sagen, dass früher ja nun nicht alles schlecht war. Ich habe auch vor der Verwendung von TDD bereits robuste und gute Software entwickelt und ich kann nicht sagen, dass ich bei jedem Projekt im Anschluss erstmal lange Zeit für Bugfixing benötigt habe. Natürlich ist der ein oder andere Fehler erst beim Klicken durch die (Web-) Anwendung aufgetaucht – aber, so what?
Und den Versuch die Architektur meiner Anwendungen zu verbessern, unternehme ich auch nicht erst seit TDD. Sicher arbeite ich nun mit wesentlich weniger Koppelungen, mache mir noch mehr Gedanken im Detail – aber ich mache noch immer Fehler oder treffe (Design-)Entscheidungen, die ich so schon beim nächsten Projekt nicht mehr treffen würde. Das Rad dreht sich eben ständig weiter. Das heißt der alte Spruch „Bitte lass mich mit meinem Code von vor zwei Jahren in Ruhe“ wird so auch mit TDD nicht an Gültigkeit verlieren.
Zum Abschluss noch etwas Konkretes. Aufgabe: in der Datenbank werden Aufgabenlisten zu einem Projekt gespeichert. Es soll ein Formular erstellt werden, über welches diese Listen erzeugt werden. Die Aufgabenlisten sind wirklich simpel:
1: public class TaskList : EntityBase
2: {
3: public int ProjectID { get; set; }
4: public string Name { get; set; }
5: public string Description { get; set; }
6: }
Ohne TDD wäre das vermutlich eine Sache von 10-15 Minuten. Mit TDD und der damit verbundenen Zeremonien ... tja, ich glaube es war etwas mehr als eine dreiviertel Stunde.
Controller Tests (70 Lines of Code)
1: [TestFixture]
2: [Category("Blubr.App.Areas.Project.Controllers.TasksController")]
3: public class Wenn_eine_Aufgabenliste_angelegt_werden_soll :
4: ConcernOfTasksController
5: {
6:
7: private ITaskService TaskService;
8:
9: public override void Setup()
10: {
11:
12: base.Setup();
13:
14: TaskService = MockRepository.DynamicMock<ITaskService>();
15:
16: Sut = new TasksController(TaskService);
17: Sut.SetCurrentProject(new Domain.Model.Project { ID = 69 });
18:
19: using(MockRepository.Record())
20: {
21: Expect.Call(TaskService.CreateTaskList(Arg<TaskList>.Matches(l => l.Name == "Valid"),
22: Arg<IValidationState>.Is.Anything)).Return(true);
23: Expect.Call(TaskService.CreateTaskList(Arg<TaskList>.Matches(l => l.Name == "Invalid"),
24: Arg<IValidationState>.Is.Anything)).Return(false);
25: }
26:
27: }
28:
29: [Test]
30: public void Wird_der_Name_gemappt()
31: {
32: Sut.CreateList(new TaskListViewModel { Name = "testname" });
33: TaskService.AssertWasCalled(s =>
34: s.CreateTaskList(Arg<TaskList>.Matches(l => l.Name == "testname"),
35: Arg<IValidationState>.Is.Anything));
36: }
37:
38: [Test]
39: public void Wird_die_Beschreibung_gemappt()
40: {
41: Sut.CreateList(new TaskListViewModel { Description = "desc" });
42: TaskService.AssertWasCalled(s =>
43: s.CreateTaskList(Arg<TaskList>.Matches(l => l.Description == "desc"),
44: Arg<IValidationState>.Is.Anything));
45: }
46:
47: [Test]
48: public void Wird_die_ProjektID_gemappt()
49: {
50: Sut.CreateList(new TaskListViewModel { Description = "desc" });
51: TaskService.AssertWasCalled(s =>
52: s.CreateTaskList(Arg<TaskList>.Matches(l => l.ProjectID == 69),
53: Arg<IValidationState>.Is.Anything));
54: }
55:
56: [Test]
57: public void Wird_zur_Übersicht_weitergeleitet_wenn_alles_geklappt_hat()
58: {
59: var result = (RedirectToRouteResult) Sut.CreateList(new TaskListViewModel { Name = "Valid" });
60: Assert.AreEqual("index", result.RouteValues["action"]);
61: }
62:
63: [Test]
64: public void Wird_das_Model_zurückgegeben_wen_nein_Fehler_aufgetreten_ist()
65: {
66: var result = (ViewResult)Sut.CreateList(new TaskListViewModel { Name = "Invalid" });
67: Assert.AreEqual("Invalid", ((TaskListViewModel) result.ViewData.Model).Name);
68: }
69:
70: }
Controller Implementierung (16 LOC)
1: [HttpPost]
2: [ValidateAntiForgeryToken]
3: public ActionResult CreateList(TaskListViewModel model)
4: {
5:
6: var taskList = new TaskList();
7: taskList.ProjectID = CurrentProject.ID;
8: taskList.Name = model.Name;
9: taskList.Description = model.Description;
10:
11: if (TaskService.CreateTaskList(taskList, new ViewModelValidationState(ModelState)))
12: return RedirectToAction("index");
13:
14: return View(model);
15:
16: }
Service Tests (99 LOC)
1: #region Wenn eine Aufgabenliste validiert wird
2:
3: [TestFixture]
4: [Category("Blubr.Domain.Services.TaskService")]
5: public class Wenn_eine_Aufgabenliste_validiert_wird : ConcernOf<TaskService>
6: {
7:
8: public override void Setup()
9: {
10: Sut = new TaskService(Arg<ITaskRepository>.Is.Anything);
11: }
12:
13: [Test]
14: public void Darf_der_Name_nicht_NullOrWhitespace_sein()
15: {
16: var validationState = new MockValidationState();
17: Sut.ValidateTaskList(new TaskList { ProjectID = 1, Name = "" }, validationState);
18: Assert.IsTrue(validationState.ContainsKey<TaskList>(l => l.Name));
19: }
20:
21: [Test]
22: [ExpectedException(typeof(ArgumentException))]
23: public void Muss_die_ProjektID_angegeben_sein()
24: {
25: var validationState = new MockValidationState();
26: Sut.ValidateTaskList(new TaskList { ProjectID = 0 }, validationState);
27: }
28:
29: [Test]
30: public void Darf_die_Beschreibung_nicht_länger_als_500_Zeichen_lang_sein()
31: {
32: throw new NotImplementedException();
33: }
34:
35: [Test]
36: public void Darf_der_Name_nicht_länger_als_100_Zeichen_lang_sein()
37: {
38: throw new NotImplementedException();
39: }
40:
41: }
42:
43: #endregion
44:
45: #region Wenn eine Aufgabenliste angelegt werden soll
46:
47: [TestFixture]
48: [Category("Blubr.Domain.Services.TaskService")]
49: public class Wenn_eine_Aufgabenliste_angelegt_werden_soll : ConcernOf<TaskService>
50: {
51:
52: private ITaskRepository TaskRepository;
53:
54: public override void Setup()
55: {
56: TaskRepository = MockRepository.DynamicMock<ITaskRepository>();
57: MockRepository.ReplayAll();
58: Sut = new TaskService(TaskRepository);
59: }
60:
61: [Test]
62: public void Wird_true_zurückgegeben_wenn_alles_geklappt_hat()
63: {
64: var result = Sut.CreateTaskList(new TaskList { ProjectID = 1, Name = "Valid" }, new MockValidationState());
65: Assert.IsTrue(result);
66: }
67:
68: [Test]
69: public void Wird_die_übergebene_Liste_validiert()
70: {
71: var validationState = new MockValidationState();
72: Sut.CreateTaskList(new TaskList { ProjectID = 1, Name = null }, validationState);
73: Assert.IsTrue(validationState.ContainsKey<TaskList>(l => l.Name));
74: }
75:
76: [Test]
77: public void Wird_false_zurückgegeben_wenn_ein_Fehler_aufgetreten_ist()
78: {
79: var result = Sut.CreateTaskList(new TaskList { ProjectID = 1, Name = null }, new MockValidationState());
80: Assert.IsFalse(result);
81: }
82:
83: [Test]
84: public void Wird_das_Erstellungsdatum_gesetzt_wenn_alles_ok_ist()
85: {
86: Sut.CreateTaskList(new TaskList { ProjectID = 1, Name = "Valid" }, new MockValidationState());
87: TaskRepository.AssertWasCalled(r => r.CreateTaskList(Arg<TaskList>.Matches(l => l.Created.Date == Sut.DateTime.Date)));
88: }
89:
90: [Test]
91: public void Wird_die_Liste_im_Repository_gespeichert_wenn_alles_ok_ist()
92: {
93: Sut.CreateTaskList(new TaskList { ProjectID = 1, Name = "Valid" }, new MockValidationState());
94: TaskRepository.AssertWasCalled(r => r.CreateTaskList(Arg<TaskList>.Is.Anything));
95: }
96:
97: }
98:
99: #endregion
Service Implementierung (37 LOC)
1: public class TaskService : ServiceBase, ITaskService
2: {
3:
4: public TaskService(ITaskRepository taskRepository)
5: {
6: TaskRepository = taskRepository;
7: }
8:
9: private readonly ITaskRepository TaskRepository;
10:
11: public bool ValidateTaskList(TaskList taskList, IValidationState validationState)
12: {
13:
14: if (taskList.ProjectID == 0)
15: throw new ArgumentException("Projekt-ID nicht gesetzt");
16:
17: if(string.IsNullOrWhiteSpace(taskList.Name))
18: validationState.AddError<TaskList>(l => l.Name, "Bitte wählen Sie einen Namen für die Aufgabenliste.");
19:
20: return validationState.IsValid;
21:
22: }
23:
24: public bool CreateTaskList(TaskList taskList, IValidationState validationState)
25: {
26:
27: if (!ValidateTaskList(taskList, validationState))
28: return false;
29:
30: taskList.Created = DateTime;
31: taskList.ID = TaskRepository.CreateTaskList(taskList);
32:
33: return true;
34:
35: }
36:
37: }
Repository Tests (66 LOC)
1: #region Wenn eine Aufgabenliste angelegt wird
2:
3: [TestFixture]
4: [Category("Blubr.Domain.Data.SqlServer.TaskRepository")]
5: public class Wenn_eine_Aufgabenliste_angelegt_wird : ConcernOfSqlServerRepository<TaskRepository, Model.TaskList, TaskList>
6: {
7:
8: public override void FixtureSetUp()
9: {
10: Sut = new TaskRepository();
11: InitializeDatabase();
12: }
13:
14: public Model.TaskList DummyTaskList()
15: {
16: var list = new Model.TaskList();
17: list.Description = "Text";
18: list.Name = "Name";
19: list.ProjectID = 69;
20: list.Created = DateTime.Now;
21: return list;
22: }
23:
24: [Test]
25: public void Wird_die_ID_zurückgegeben()
26: {
27: var id = Sut.CreateTaskList(DummyTaskList());
28: Assert.AreNotEqual(0, id);
29: }
30:
31: [Test]
32: public void Wird_der_Name_gespeichert()
33: {
34: var id = Sut.CreateTaskList(DummyTaskList());
35: var list = Sut.Database.TaskLists.Single(l => l.ID == id);
36: Assert.AreEqual("Name", list.Name);
37: }
38:
39: [Test]
40: public void Wird_die_Beschreibung_gespeichert()
41: {
42: var id = Sut.CreateTaskList(DummyTaskList());
43: var list = Sut.Database.TaskLists.Single(l => l.ID == id);
44: Assert.AreEqual("Text", list.Description);
45: }
46:
47: [Test]
48: public void Wird_die_ProjektID_gespeichert()
49: {
50: var id = Sut.CreateTaskList(DummyTaskList());
51: var list = Sut.Database.TaskLists.Single(l => l.ID == id);
52: Assert.AreEqual(69, list.FKProjectID);
53: }
54:
55: [Test]
56: public void Wird_das_Erstellungsdatum_gespeichert()
57: {
58: var dummy = DummyTaskList();
59: var id = Sut.CreateTaskList(dummy);
60: var list = Sut.Database.TaskLists.Single(l => l.ID == id);
61: Assert.AreEqual(dummy.Created, list.Created);
62: }
63:
64: }
65:
66: #endregion
Repository Implementierung (21 LOC)
1: public class TaskRepository : RepositoryBase<Model.TaskList, TaskList>, ITaskRepository
2: {
3:
4: public int CreateTaskList(Model.TaskList taskList)
5: {
6: var newTaskList = new TaskList();
7: newTaskList.Created = taskList.Created;
8: newTaskList.Name = taskList.Name;
9: newTaskList.FKProjectID = taskList.ProjectID;
10: newTaskList.Description = taskList.Description;
11: Database.TaskLists.InsertOnSubmit(newTaskList);
12: Database.SubmitChanges();
13: return newTaskList.ID;
14: }
15:
16: public override Model.TaskList Map(TaskList source)
17: {
18: throw new NotImplementedException();
19: }
20:
21: }
Mein (Zwischen-)Fazit:
TDD ist cool, TDD ist nützlich, TDD verhilft zu besserer Software. Aber ist TDD auch alltagstauglich? Sollte ich es konsequent für alle Projekte, die jenseits von C:\Development\Tests\WebsiteProject12 hinausgehen einsetzen? Oder sollte ich zu alter Produktivität zurückkehren, ein paar mehr Fehler in Kauf nehmen? Die Antwort muss ich euch schuldig bleiben - mir schwirrt da vieles durch den Kopf, eine Entscheidung habe ich noch nicht getroffen.