Your Automated System Tests Should Be a Joy to Write
Are your automated system tests a rat's nest of unmaintainable code? Unmaintainable system test code is usually the result of app-code developers putting their filthy paws on innocent test procedures.
There are two keys to writing maintainable system tests - write the test part like a procedure and write the helpers like an object-oriented interface. For now, I want to focus on writing your test like a procedure.
Most app-code developers don't like writing linear procedures. They like loops and super-commonized methods with pithy names. As soon as a linear procedure does the same thing twice, they want to pull that out into a common helper method and slap a pithy name on it. You must fight this. Stand your ground! Take their pithy-named helper method and throw it directly in the trash.
How do you know when a helper method should go in the trash? Stick to the language of your domain! Stick to the language of your product. Let's look at an example.
Let's say that your application lets a user manage Documents. Due to the particular needs of your user, you must not only provide them the ability to revise and update Documents with the New Version feature, but they would also like a Copy feature so they can make a new Document without starting from scratch. The 'New Version' and 'Copy' features have just been implemented. One of the app code developers, Bobbie, implemented both features and is just about to write the automated system test too. Very responsible move on Bobbie's part.
Bobbie thinks "no problem - I know how to test this." They come up with a test that has a procedure like this:
ShouldBeAbleToCreateNewVersionOrCopyDocument()
{
// Make a new Document
// Populate it with some random test data
// Take a snapshot of the data
// Create a new version of the document
// Verify the new version looks the same as the snapshot
// Create another new document
// Populate it with ...
// wait a sec, I'm about to write the same procedure!
// I mean, it will be almost exactly the same -
// I just have to copy instead of create a new version
// I bet I could make a common method for this,
// then I could save myself some typing!
}
Bobbie rewrites the test using a helper method. Now it looks something like this:
// copying and new version are both ... duplicating, right?
DuplicateDocumentTest()
{
// Make a new Document
// Populate it with some random test data
// Take a snapshot of the data
// Create a new ... wait, *duplicate* the Document
// Hm, I'll have to come up with some way to either
// copy or create a new version
// I guess I could pass in a lambda function
// I'll just create a boolean for it
}
So, now Bobbie is creating a boolean with a variable name something like shouldCreateNewDraft, which makes sense when it's true, but if it's false it creates a new copy instead. Bobbie justifies that logic when picking what button to click on, but when it's time to update this procedure because someone needs to verify the Document has the right version number, now true equals 2 and false equals 1 and the method is starting to have more and more if-blocks.
Soon, the if-blocks are not only multiplying, but they're mutating into nested if-blocks. The helper method is hundreds of lines long and has more curly braces than some entire applications. The helper method absorbs other healthy areas of code and infects them. You need to cut an entire library out of your code base before the infection spreads. Except it's already too late. Your code base has turned on you. It's draining your life energy and soon it will kill all life on earth. All because Bobbie tried to lump two distinct ideas into one helper method.
Are all helper methods bad? It's true to say that we shouldn't have to spell out every little thing we want to do in the procedure every time. We need to put some things in common helper methods. So, where is the cut off? When is it okay to move code into a helper method and when isn't it?
One of the easiest ways to tell is if you are sticking to the language of the domain. In our example, Create New Version and Copy were both words that the user would see in the application. They're part of the domain. The word Duplicate is not part of our application. For our purposes, the word Duplicate is undefined. For all we know, we'll be given requirements for a 'duplicate' feature down the road. At that point you might find out that 'duplicate' doesn't even apply to Documents - it applies to Road Maps or something else - now the cleverness you displayed earlier finding a word that covers both really makes the tests that much more confusing.
Let’s consider an example that does deserve to be a helper method. Take the step ‘Make a new Document’. For most applications, making a new *anything* is probably a multi-step process. But, if making new Documents is a really key feature of your application, then it’s probably really easy to do. There may have been some options along the way such as choosing the paper size or the color template. However, doing those things should be easy for even a new user to do. Also, they don’t matter to the test. When I use the Copy feature, my test shouldn’t care if I chose A4 paper or napkin paper as my paper type as long as it’s the same after I Copy. If it’s not a common task or if it depends on data that will impact your test, don’t put it in a helper method!
Another thing we should have noticed early on is that the original test had the word 'Or' in it. If we had avoided test names with 'and' or 'or' in them, we might have avoided this question all together. It's a good rule for all your method names - test names or helper names - that they should only have one purpose.
I know that Bobbie probably implemented these features with one method in the app code. It probably made sense in the app code to do it that way. That doesn't mean it's only one system test. When writing a system test, you are a representative of the user. To the user, these are two separate use cases. Let your system tests spell out the use cases for you.
Let your tests match the use cases. Speak the user's language with your tests. Bobbie will have some rework to do and there's no way around it. You need to protect the system tests. And maybe all life on earth.
There are two keys to writing maintainable system tests - write the test part like a procedure and write the helpers like an object-oriented interface. For now, I want to focus on writing your test like a procedure.
Most app-code developers don't like writing linear procedures. They like loops and super-commonized methods with pithy names. As soon as a linear procedure does the same thing twice, they want to pull that out into a common helper method and slap a pithy name on it. You must fight this. Stand your ground! Take their pithy-named helper method and throw it directly in the trash.
How do you know when a helper method should go in the trash? Stick to the language of your domain! Stick to the language of your product. Let's look at an example.
Let's say that your application lets a user manage Documents. Due to the particular needs of your user, you must not only provide them the ability to revise and update Documents with the New Version feature, but they would also like a Copy feature so they can make a new Document without starting from scratch. The 'New Version' and 'Copy' features have just been implemented. One of the app code developers, Bobbie, implemented both features and is just about to write the automated system test too. Very responsible move on Bobbie's part.
Bobbie thinks "no problem - I know how to test this." They come up with a test that has a procedure like this:
ShouldBeAbleToCreateNewVersionOrCopyDocument()
{
// Make a new Document
// Populate it with some random test data
// Take a snapshot of the data
// Create a new version of the document
// Verify the new version looks the same as the snapshot
// Create another new document
// Populate it with ...
// wait a sec, I'm about to write the same procedure!
// I mean, it will be almost exactly the same -
// I just have to copy instead of create a new version
// I bet I could make a common method for this,
// then I could save myself some typing!
}
Bobbie rewrites the test using a helper method. Now it looks something like this:
// copying and new version are both ... duplicating, right?
DuplicateDocumentTest()
{
// Make a new Document
// Populate it with some random test data
// Take a snapshot of the data
// Create a new ... wait, *duplicate* the Document
// Hm, I'll have to come up with some way to either
// copy or create a new version
// I guess I could pass in a lambda function
// I'll just create a boolean for it
}
So, now Bobbie is creating a boolean with a variable name something like shouldCreateNewDraft, which makes sense when it's true, but if it's false it creates a new copy instead. Bobbie justifies that logic when picking what button to click on, but when it's time to update this procedure because someone needs to verify the Document has the right version number, now true equals 2 and false equals 1 and the method is starting to have more and more if-blocks.
Soon, the if-blocks are not only multiplying, but they're mutating into nested if-blocks. The helper method is hundreds of lines long and has more curly braces than some entire applications. The helper method absorbs other healthy areas of code and infects them. You need to cut an entire library out of your code base before the infection spreads. Except it's already too late. Your code base has turned on you. It's draining your life energy and soon it will kill all life on earth. All because Bobbie tried to lump two distinct ideas into one helper method.
Are all helper methods bad? It's true to say that we shouldn't have to spell out every little thing we want to do in the procedure every time. We need to put some things in common helper methods. So, where is the cut off? When is it okay to move code into a helper method and when isn't it?
One of the easiest ways to tell is if you are sticking to the language of the domain. In our example, Create New Version and Copy were both words that the user would see in the application. They're part of the domain. The word Duplicate is not part of our application. For our purposes, the word Duplicate is undefined. For all we know, we'll be given requirements for a 'duplicate' feature down the road. At that point you might find out that 'duplicate' doesn't even apply to Documents - it applies to Road Maps or something else - now the cleverness you displayed earlier finding a word that covers both really makes the tests that much more confusing.
Let’s consider an example that does deserve to be a helper method. Take the step ‘Make a new Document’. For most applications, making a new *anything* is probably a multi-step process. But, if making new Documents is a really key feature of your application, then it’s probably really easy to do. There may have been some options along the way such as choosing the paper size or the color template. However, doing those things should be easy for even a new user to do. Also, they don’t matter to the test. When I use the Copy feature, my test shouldn’t care if I chose A4 paper or napkin paper as my paper type as long as it’s the same after I Copy. If it’s not a common task or if it depends on data that will impact your test, don’t put it in a helper method!
Another thing we should have noticed early on is that the original test had the word 'Or' in it. If we had avoided test names with 'and' or 'or' in them, we might have avoided this question all together. It's a good rule for all your method names - test names or helper names - that they should only have one purpose.
I know that Bobbie probably implemented these features with one method in the app code. It probably made sense in the app code to do it that way. That doesn't mean it's only one system test. When writing a system test, you are a representative of the user. To the user, these are two separate use cases. Let your system tests spell out the use cases for you.
Let your tests match the use cases. Speak the user's language with your tests. Bobbie will have some rework to do and there's no way around it. You need to protect the system tests. And maybe all life on earth.
Comments
Post a Comment