Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to avoid the creation of poor designs with TDD

Tags:

tdd

mocking

I have recently (in the last week) embarked on an experiment wherein I attempt to code a new feature in a project I'm working on using TDD principles. In the past, our approach has been a moderately-agile approach, but with no great rigour. Unit testing happens here and there when it's convenient. The main barrier to comprehensive test coverage is that our application has a complicated web of dependencies. I picked a feature that was convenient to wall off to try my experiment on; the details aren't important and probably commercially sensitive, suffice to say that it's a simple optimisation problem.

Thus far I have found that:

  • TDD for me seems to encourage rambling, non-obvious designs to take shape. The restriction that one must not write code without a test tends to block opportunities to factor out functionality into independent units. Thnking up and writing tests for that many features simultaneously is too difficult in practice
  • TDD tends to encourage the creation of 'God Objects' that do everything - because you've written lots of mocking classes for class x already, but few for class y, so it seems logical at the time that class x should also implement feature z instead of leaving it to class y.
  • Writing tests before you write code requires that you have a complete understanding of every intricacy of the problem before you solve it. This seems like a contradiction.
  • I haven't been able to get the team on-side to start using a mocking framework. This means that there is a proliferation of cruft created solely to test a particular feature. For every method tested, you'll tend to need a fake whose only job is to report that the class under test called whatever it's supposed to. I'm starting to find myself writing something resembling a DSL purely for instantiating the test data.
  • Despite the above concerns, TDD has produced a working design with few mysterious errors, unlike the development pattern I'm used to. Refactoring the sprawling mess that results however has required that I temporarily abandon the TDD and just get it done. I'm trusting that the tests will continue to enforce correctness in the method. Trying to TDD the refactoring exercise I feel will just proliferate more cruft.

The question then, is "Does anybody have any advice to reduce the impact of the concerns listed above?". I have no doubt that a mocking framework would be advantageous; however at present I'm already pushing my luck trying something that appears to merely produce rambling code.

edit #1:

Thank you all for your considered answers. I admit that I wrote my question after a few friday-evening beers, so in places it's vague and doesn't really express the sentiments that I really intended. I'd like to emphasise that I do like the philosophy of TDD, and have found it moderately successful, but also surprising for the reasons I listed. I have the opportunity to sleep on it and look at the problem again with fresh eyes next week, so perhaps I'll be able to resolve my issues by muddling through. None of them are non-starters, however.

What concerns me more is that some of the team members are resistant to trying anything that you could call a 'technique' in favour of 'just getting it done'. I am concerned that the appearance of cruft will be taken as a black mark against the process, rather than evidence that it needs to be done completely (i.e. with a mocking framework and strong DI) for best results.

RE "TDD doesn't have to mean test-first": (womp, btreat)

The 'golden rule' in every text I've found on the issue is "Red, Green, Refactor". That is:

  • Write a test that MUST fail
  • Write code that passes the test
  • Refactor the code so that it passes the test in the neatest practical way

I am curious as to how one imagines doing Test-Driven Development without following the core principle of TDD as originally written. My colleague calls the halfway house (or a different and equally valid approach, depending on your perspective) "Test-Validated Development". In this case I think coining a new term - or possibly stealing it off somebody else and taking credit for it - is useful.

RE DSLs for test data: (Michael Venable)

I'm glad you said that. I do see the general form being increasingly useful across the scope of the project, as the application in question maintains a pretty complicated object graph and typically, testing it means running the application and trying things out in the GUI. (Not going to give the game away for commercial sensitivity reasons above, but it's fundamentally to do with optimisation of various metrics on a directed graph. However, there are lots of caveats and user-configurable widgets involved.)

Being able to set up a meaningful test case programmatically will help in all manner of situations, potentially not limited to unit testing.

RE God Objects:

I felt this way because one class seemed to be taking up most of the feature-set. Maybe this is fine, and it really is that important, but it raised a few eyebrows because it looked just like older code that wasn't developed along these lines, and appeared to violate SRP. I suppose it's inevitable that some classes will function primarily as seams between numerous different encapsulated bits of functionality and others will seam only a few. If it's going to be that way, I suppose what I need to do is purge as much of the logic as possible from this apparent God Object and recast its behaviour as a junction point between all the factored-out parts.

(to the moderators: I've added my responses to posts up here because the comment field isn't long enough to contain the detail I'd like.)

edit #2 (after about five months):

Well, I felt it might be nice to update with some more thoughts after mulling the issue over for a while.

I did end up abandoning the TDD approach in the end, I'm sorry to say. However, I feel that there are some specific and justified reasons for this, and I'm all ready to continue with it the next time I get an opportunity.

A consequence of the unapologetic refactoring mentality of TDD is that I was not greatly upset when, upon taking a brief look at my code, the lead dev declared that the vast majority of it was pointless and needed to go. While there is a twinge of regret at having to cast off a huge swathe of hard work, I saw exactly what he meant.

This situation had arisen because I took the rule of 'code to an interface' literally, but continued to write classes that tried to represent reality. Quite a long time ago I first made the statement:

Classes should not attempt to represent reality. The object model should only attempt to solve the problem at hand.

...which I have repeated as often as I can since; to myself and to anybody else who will listen.

The result of this behaviour was an object model of classes that performed a function, and a mirroring set of interfaces which repeated the functionality of the classes. Having had this pointed out to me and after a brief but intense period of resistance, saw the light and had no problem with deleting most of it.

That doesn't mean that I believe 'code to an interface' is bunk. What it does mean is that coding to an interface is primarily valuable when the interfaces represent real business functions, rather than the properties of some imagined perfect object model that looks like a miniature copy of real life, but doesn't consider its sole meaning in life to be answering the question you originally asked. The strength of TDD is that it can't produce models like this, except by chance. Since it starts with asking a question and only cares about getting an answer, your ego and prior knowledge of the system aren't involved.

I'm rambling now, so I'd do best to finish this and just state that I am all raring to go at trying TDD again, but have a better overview of the tools and tactics available and will do my best to decide how I want to go about it before jumping in. Perhaps I should transplant this waffle to a blog where it belongs, once I have something more to say about it.

like image 982
Tom W Avatar asked Jun 03 '11 16:06

Tom W


People also ask

What are the major challenges faced when performing test driven data based development?

The main problem with Test-Driven Development, is that unit testing is not a measure of correctness but a measure of predictable behavior. Unit tests guarantee that our code behaves as we expected it to, but the expected behavior might be incorrect, incomplete or functional only on happy flows.

Does TDD helps the design process?

Using TDD you build up, over time, a suite of automated tests that you and any other developer can rerun at will. Better Designed, cleaner and more extensible code. It helps to understand how the code will be used and how it interacts with other modules. It results in better design decision and more maintainable code.

Why is TDD not good?

TDD is Time Consuming and Costly, in both Short Term and Long Term. In previous section we've already discussed why TDD is time consuming in short term: you have to spend significant time on refactoring and rewriting your code. But in the long term it will cost more time as well. Remember, test cases are code, too.

How does TDD improve code quality?

Test-driven development (TDD) is a software development practice that supposedly leads to better quality and fewer defects in code. TDD is a simple practice, but developers sometimes do not apply all the required steps correctly.


2 Answers

TDD isn't mocking. Sometimes, mocking facilitates development of tests, but if in your first pass at TDD you're starting out with mocks, you are probably not getting the best possible introduction to the practice.

TDD does not, in my experience, lead to god objects; quite the opposite. TDD leads me to classes that do fewer things and interact with fewer other classes, fewer dependencies.

The restriction that one must not write code without a test tends to block opportunities to factor out functionality into independent units. Thnking up and writing tests for that many features simultaneously is too difficult in practice.

This really does not sound right to me. You are not trying to write tests for many features simultaneously; you are trying to write one test, for one feature, at a time. When that test is written, you make it pass. When it's passing, you make it clean. Then you write your next test, possibly driving further development of the same feature, until the feature is done-done, and clean. Then you write the next test for your next feature.

Writing tests before you write code requires that you have a complete understanding of every intricacy of the problem before you solve it. This seems like a contradiction.

Again: write one test. That requires a complete understanding of one aspect of one feature. It requires it, and it expresses it concretely, in executable form.

like image 145
Carl Manaster Avatar answered Feb 22 '23 16:02

Carl Manaster


Just as a blanket response to the problems you're having, it sounds like you haven't been using TDD very long, you may not be using any tools that may help with the TDD process, and you're putting more value on the line of production code than the line of testing code.

More specifically to each point:

1: TDD encourages a design that does no more or less than what it has to, aka a "YAGNI" (You ain't gonna need it) approach. That's "do it light". You have to balance that with "do it right", which is to incorporate the proper SOLID design concepts and patterns into the system. I take the following rule of thumb: On the first usage of a line of code, make it work. On the second reference to that line, make it readable. On the third, make it SOLID. If a line of code is only used by one other line of code, it doesn't make much sense at that time to put in a fully SOLID design, breaking the code out into an interface-abstracted class that can be plugged in and swapped out. However, you have to have the discipline to go back and refactor code once it starts gaining other uses. TDD and Agile design is ALL about refactoring. Here's the rub; so is Waterfall, it just costs more because you have to all the way back to the design phase to make the change.

2: Again, this is discipline. Single Responsibility Principle: an object should do one specific thing and be the only object in the system that does that thing. TDD does not permit you to be lazy; it just helps you find out where you CAN be lazy. Also, if you need to create a lot of partial mocks of a class, or a lot of highly-featured full mocks, you're probably architecting the objects and tests incorrectly; your objects are too big, your SUT has too many dependencies, and/or the scope of your test is too broad.

3: No it doesn't. It requires you to think about what you will need as you write your test suite. Here's where refactoring assistants like ReSharper (for MSVS) really shine; Alt+Enter is your "do it" shortcut. Let's say you are TDDing a new class that will write out a report file. The first thing you do is new up an instance of that class. "Wait", ReSharper complains, "I can't find that class!". "So create it", you say, hitting Alt+Enter. And it does so; you now have an empty class definition. Now you write a method call up in your test. "Wait," ReSharper cries, "that method doesn't exist!", and you say "then create it" with another press of Alt+Enter. You've just programmed-by-testing; you have the skeletal structure for your new logic.

Now, you need a file to write to. You start by typing in a filename as a string literal, knowing that when RS complains you can simply tell it to add the parameter to the method definition. Wait, that's not a unit test. That requires the method you're creating to touch the file system, and you then have to get the file back and go through it to make sure it is correct. So, you decide to pass a Stream instead; that allows you to pass in a MemoryStream, which is perfectly unit-test-compatible. There's where TDD influences design decisions; in this case, the decision is to make the class more SOLID up front so it can be tested. The same decision gives you the flexibility to pipe the data anywhere you want in the future; into memory, a file, over the network, a named pipe, whatever.

4: An Agile team programs by agreement. If there is no agreement, that's a block; if the team is blocked, no code should be written. To resolve the block, the team lead or the project manager makes a command decision. That decision is right until proven wrong; if it ends up wrong, it should do so quickly, so the team can go in a new direction without having to backtrack. In your specific case, have your manager make a decision - Rhino, Moq, whatever - and enforce it. Any of that will be a thousand percent better than hand-writing testing mocks.

5: This should be the real strength of TDD. You have a class; its logic is a mess, but it is correct and you can prove it by running the tests. Now, you start refactoring that class to be more SOLID. If the refactoring doesn't change the external interface of the objects, then the tests don't even have to change; you're just cleaning up some method logic that the tests don't care about except that it works. If you DO change the interface, then you change the tests to make different calls. This requires discipline; it is very easy to just gut a test that doesn't work anymore because the method being tested doesn't exist. But, you have to make sure that all the code in your object is still adequately exercised. A code coverage tool can help here, and it can be integrated into a CI build process and "break the build" if the coverage isn't up to snuff. However, the flip side of coverage is actually twofold: first, a test that adds coverage for coverage's sake is useless; every test must prove that code works as expected in some novel situation. Also, "coverage" is not "exercise"; your test suites may execute every line of code in the SUT, but they may not prove that a line of logic works in every situation.

All that said, there was a very powerful lesson in what TDD will and won't do given to me when I first learned it. It was a coding dojo; the task was to write a Roman numeral parser that would take a Roman numeral string and return an integer. If you understand the rules of Roman numerals, this is easy to design up front and can pass any test given. However, a TDD discipline can, very easily, create a class that contains a Dictionary of all the valued specified in the tests and their integers. It happened in our dojo. Here's the rub; if the actual stated requirements of the parser were that it handled only the numbers we tested, we did nothing wrong; the system "works" and we didn't waste any time designing something more elaborate that works in the general case. However, us new Agilites looked at the morass and said this approach was stupid; we "knew" it had to be smarter and more robust. But did we? This is TDD's power and its weakness; you can design nothing more or less than a system that meets the stated requirements of the user, because you should not (and often cannot) write code that doesn't meet or prove some requirement of the system given to you by the person paying the bills.

Although I do quite a bit of post-development test writing, there is a major problem with doing so; you have already written the production code, and hopefully tested it in some other manner. If it fails your test now, who's wrong? If it's the test, then you change the test to assert that what the program is currently outputting is correct. Well that's not much use; you just proved that the system outputs what it always has. If it's the SUT, then you have bigger problems; you have an object you have already fully developed that doesn't pass your new test, and now you have to tear it open and change stuff to make it do so. If that's your only automated test of this object to date, who knows what you'll break to pass this one test? TDD, instead, forces you to write the test before incorporating any new logic that will pass that test, and as a result you have regression-proof code; you have a suite of tests that prove that the code meets current requirements, before you start adding new ones. So, if existing tests fail when the code is added, you broke something, and you shouldn't commit that code for a release until it passes all the tests that were already there AND all the new ones.

If there's a conflict in your tests, that's a block. Say you have a test that proves a given method returns X given A, B, and C. Now, you have a new requirement, and in developing the tests you discover that now the same method must output Y when given A, B, and C. Well, the prior test is integral to proving the system worked the old way, so changing that test to prove it now returns Y may break other tests built on that behavior. To resolve this, you have to clarify that either the new requirement is a change in behavior from the old one, or that one of the behaviors was incorrectly inferred from the acceptance requirements.

like image 32
KeithS Avatar answered Feb 22 '23 16:02

KeithS