In the final section of part 1 of this series, Steve Upton and I mentioned that feature toggles should be properly tested; we also briefly described our approach. This part will go into more detail and show a hands-on example of what the lifecycle of a feature toggle looks like and how testing toggles helps us mitigate technical debt.
听
The key aspect we want to keep in mind when developing a feature is that even when a feature is unreleased, all states of the toggle must remain valid. This is particularly important when the feature is not an addition, but rather a modification or improvement of existing behavior. In other words, we want to be sure that the original functionality is still working as expected.
听
In this article, I will be using an example for Java, with Spring Boot and as a feature toggle implementation. Such an approach should be seen as equivalent and applicable to other implementations of toggles. This article鈥檚 example is inspired by the sample implementation of the Togglz library 鈥溾, which was created by one of the Togglz maintainers, Bennet Schulz.
听
Introducing the example
To begin, we have nothing but a very simple page for our web application. It has a HTTP status code 400 (bad request), since there is currently nothing to show. This is our controller:
@RestController public class HelloWorldController { @RequestMapping("/") public ResponseEntity<?> index() { return ResponseEntity.badRequest().build(); } }
Our existing test class looks like this:
@SpringBootTest @AutoConfigureMockMvc class HelloWorldControllerIntegrationTests { @Autowired private MockMvc mockMvc; @Test void badRequest() throws Exception { mockMvc .perform(get("/")) .andExpect(status().isBadRequest()); } }
We do not have any features yet:
public enum Features implements Feature {
}
Finally, we need a Togglz-specific configuration class. This might be different if you are using another library.
@Configuration public class TogglzConfiguration { 听听听听@Bean public FeatureProvider featureProvider() { return new EnumBasedFeatureProvider(Features.class); } }
Implementing the Hello World feature
听
We picked up story number #2 and start to implement. Our first feature is a page that displays 鈥淗ello World鈥 to our customers and returns the HTTP status 200 (ok). The first thing we do before writing any other code is create the feature toggle:
听
public enum Features implements Feature { 听 听 听@Label("Display Welcome Message") 听 听 听S2_HELLO_WORLD }
听
It makes sense to give the toggle a name which relates it back to the story. This ensures other developers can easily figure out where it is coming from. Now that our toggle is ready, we can enable it on nonlive environments and disable it on live 鈥 this allows us to commit and push our upcoming changes. Next, let鈥檚 write a failing test for our feature in the HelloWorldControllerIntegrationTests test class:
class HelloWorldControllerIntegrationTests { // existing code omitted @Autowired private StateRepository store; // from Togglz library 听@BeforeEach void setUp() { Arrays .stream(Features.values()) .forEach(feat -> store .setFeatureState(new FeatureState(feat, true))); } @Test void greeting() throws Exception { mockMvc.perform(get("/")).andExpect(status().isOk()) .andExpect(content().string("Hello World")); } }
听
The StateRepository store is where Togglz stores all the toggles鈥 current state. Note how we added a setup step to the test. This is to enable all feature toggles defined in the Features enum by default within this test class. This ensures our new feature integrates with other parts of the application and all other features that are in progress. Depending on the toggle library you use, this step will probably be different. If you are using Togglz, the also includes some annotations to make this a bit more comfortable for you. It was excluded here to keep the code a bit more generic.
听
Our greeting test is failing now as expected. Let鈥檚 make it green by modifying the controller:
听
public class HelloWorldController { @RequestMapping("/") public ResponseEntity<?> index() { if (Features.S2_HELLO_WORLD.isActive()) { StringBuilder sb = new StringBuilder("Hello World"); return ResponseEntity.ok().body(sb.toString()); } return ResponseEntity.badRequest().build(); } }
听
Success: our greeting test is green. But there鈥檚 a problem: our badRequest test is red! What should we do? We will not need the badRequest test once our story is completed, but while our story has not been released yet, we don鈥檛 want to change the original behavior. So let鈥檚 keep it and disable S2_HELLO_WORLD for it:听
听
class HelloWorldControllerIntegrationTests { // existing code omitted @Test // Renamed test with disabled toggle void LEGACY_badRequest() throws Exception { store.setFeatureState(new FeatureState(Features.S2_HELLO_WORLD, false)); mockMvc .perform(get("/")) .andExpect(status().isBadRequest()); } }
Now it鈥檚 back to green! Note that we also renamed the test with the LEGACY_ prefix. This is a personal preference of mine to signal to other developers that this test will soon not be needed anymore. Figure out with your team how you want to handle soon-to-be-deprecated tests to achieve consistent alignment. Once the second story is released and the team agrees to clean up the toggle, this test can be removed with it.
听
Implementing the reverse greeting feature
听
While story number two is waiting to be released, we can pick up story number three. Instead of 鈥淗ello World鈥, we want to display the reversed greeting 鈥渄lroW olleH鈥 at some point in the future. Our manager wants the feature ready to be tested and released once story number two has been released. To do this, we create a new toggle (omitted for brevity) and a new test for it:
class HelloWorldControllerIntegrationTests { // existing code omitted @Test void reverseGreeting() throws Exception { mockMvc.perform(get("/")).andExpect(status().isOk()) .andExpect(content().string("dlroW olleH")); } }
As expected, the test fails. So we next implement the new feature behind a toggle. This time, we do something which we usually want to avoid because it can become very complicated to test and debug: we cascade toggles. Since we are confident that these toggles will be short-lived, we accept the temporary nesting of if-statements. If we expect the feature release process to take some time or if the team isn鈥檛 committed to cleaning up the toggles quickly, we should find another way to achieve our goal.
听
public class HelloWorldController { @RequestMapping("/") public ResponseEntity<?> index() { if (Features.S2_HELLO_WORLD.isActive()) StringBuilder sb = new StringBuilder("Hello World"); if (Features.S3_REVERSE_GREETING.isActive()) { sb.reverse(); } return ResponseEntity.ok().body(sb.toString()); } return ResponseEntity.badRequest().build(); } }
Maybe this isn鈥檛 the best solution, but it works. Just like how we handle toggles, there are many ways to achieve our goals. Again, our new test is green but our old test has become red 鈥 this time the greeting test. So, we disable the S3_REVERSE_GREETING for it and mark it as legacy. Once we clean up story number three鈥檚 toggle, this test will become unnecessary.
听
class HelloWorldControllerIntegrationTests { // existing code omitted @Test void LEGACY_greeting() throws Exception { store.setFeatureState(new FeatureState(Features.S3_REVERSE_GREETING, false)); mockMvc.perform(get("/")).andExpect(status().isOk()) .andExpect(content().string("Hello World")); } }
听
If you want to be a bit more sophisticated, you can build an annotation for legacy tests which states the toggles associated with the test instead of adding the LEGACY_ prefix.
听
Now both of our stories are ready to be released.
听
Cleaning up the 鈥淗ello World鈥 toggle
Our product owner decided to release story number two 鈥 people love it! We don鈥檛 want to go back to the bad request page and have to clean up the tech debt which has been introduced with the toggle. Let鈥檚 search for occurrences of the S2_HELLO_WORLD toggle in the code. Let鈥檚 start with the production code and see which tests we are breaking. To remove the toggle, remove the if and only keep the code within the block from the controller. The return of the badRequest will cause a compiler error since it鈥檚 unreachable and can also be removed.
public class HelloWorldController { @RequestMapping("/") public ResponseEntity<?> index() { StringBuilder sb = new StringBuilder("Hello World"); if (Features.S3_REVERSE_GREETING.isActive()) { sb.reverse(); } return ResponseEntity.ok().body(sb.toString()); } }
听
This makes the LEGACY_badRequest test break. That isn鈥檛 a problem, though 鈥 we can just delete it since it鈥檚 marked as legacy. Now the only occurrence of S2_HELLO_WORLD is in the Features enum. Let鈥檚 delete it there, commit and push. Our cleanup is done!
听
Cleaning up the reverse greeting toggle
Story number three was also released, but people鈥檚 reaction to it was rather confused. Our product owner toggled the feature off quickly and asked us to remove it altogether. The cleanup works in almost the same way, but this time we remove the entire if block for the toggle:
听
public class HelloWorldController { @RequestMapping("/") public ResponseEntity<?> index() { StringBuilder sb = new StringBuilder("Hello World"); return ResponseEntity.ok().body(sb.toString()); } }
This breaks our reverseGreeting test. That isn鈥檛 a problem though since we want to get rid of the feature anyway. Next, we search for other occurrences of S3_REVERSE_GREETING. Unfortunately, it turns out we disabled the toggle for one legacy test. Since we want to keep the state as it was before we reversed the greeting, let鈥檚 unmark the test and remove the toggle from it. It now looks exactly like before, when we implemented it in story number two. Now we can remove the toggle from the Features enum because it鈥檚 the only remaining occurrence of this toggle.
听
Finally, there are no more toggles used by our controller. This means we can remove some overhead in the setup of the test which we introduced for the toggles. After we do that, it looks like this:
听
class HelloWorldControllerIntegrationTests { @Autowired private MockMvc mockMvc; @Test void greeting() throws Exception { mockMvc.perform(get("/")).andExpect(status().isOk()) .andExpect(content().string("Hello World")); 听} }
Conclusion
听
This was a simplified and hands-on demonstration of how you can implement a feature with a toggle and clean it up (regardless of whether the feature shall stay live or should be revoked). While this is done it is important to keep in mind that all possible states should be properly tested. If you want to use feature toggles, align with your team on how you want to deal with tests and cleanups. If you don鈥檛, there is a risk that the toggle tech debt gets more and more complicated. This will make cleaning up a toggle a hazard of its own.
听
To reduce the amount of tests you need to write and update for each toggle, consider implementing your , as Pete Hodgson and Carol Jang propose. As always, when dealing with feature toggles there are many approaches you can follow. For a different perspective, check out .
听
Read the rest of this series:
听
- Part one: Managing feature toggles in teams
- Part two: The limits of feature toggles
- Part three: Feature toggles and database migrations
- Part five: Static vs dynamic feature toggles
Disclaimer: The statements and opinions expressed in this article are those of the author(s) and do not necessarily reflect the positions of 黑料门.