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 Togglz as a feature toggle implementation. Such an approach should be seen as equivalent and applicable to other implementations of toggles. This article’s example is inspired by the sample implementation of the Togglz library “hello-world-feature-enum”, 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 “Hello 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’s 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 Togglz testing library 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’s 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’s 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’t want to change the original behavior. So let’s 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’s 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 “Hello World”, we want to display the reversed greeting “dlroW 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’t 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’t 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’s 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 “Hello World” toggle
Our product owner decided to release story number two — people love it! We don’t 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’s search for occurrences of the S2_HELLO_WORLD toggle in the code. Let’s 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’s 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’t a problem, though — we can just delete it since it’s marked as legacy. Now the only occurrence of S2_HELLO_WORLD is in the Features enum. Let’s delete it there, commit and push. Our cleanup is done!
Cleaning up the reverse greeting toggle
Story number three was also released, but people’s 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’t 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’s 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’s 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’t, 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 toggles as keystones, 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 Louise Gibbs’ take on automated tests with feature toggles.
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 Thoughtworks.