Abstraction is about expressing ideas and simplifying complexity. However, it is quite often hard to understand what line not to cross. When does abstraction become too much and more binding than freeing?
Here are some indicators that point to over-abstraction:
- When I have a component and I need to drill three layers deep to actually find the behavior.
- When I build a component that iterates over a series of records, knowing that there will only ever be two records.
- When I have two components that do nearly the same thing but they do one thing slightly differently. Then, when I have to change something, I have to change both components, for the same reason.
- Unrelated tests fail as I build out additional behaviors for a feature.
These are all mistakes that I have made at some point during feature building. I’ve realized these were mistakes when I had to add feature functionality and the abstraction I had chosen caused pain.
YAGNI — You Aren’t Going To Need It
Don’t add abstraction for abstraction’s sake. This will bind you to an abstraction that may be the wrong one. Wrong abstractions lead to pain.
Make sure things are truly the same but not coincidentally the same. Sometimes components appear to have the same behavior but down the road, they aren’t really.
Sometimes I wait until I have multiple components that do nearly the same thing before I add an abstraction that extracts the commonalities. This allows me to identify what abstraction is really needed.
Code Reuse is a Form of Abstraction
Wrapping blocks of code in a component makes it easier to manage and removes duplication. It is important to identify the right behavior that is being reused.
When two different components do nearly the same thing, think about using composition and polymorphism to plug and play the things that are different. This reduces duplication and still allows for variability.
This is applicable even with smaller blocks of code, such as functions. When wrapping code to provide abstraction, name the function based on what it is doing. If you have to drill into the function to get a high-level sense of what it is doing, it is named wrong, or it is doing too many things.
Treat Tests Like Production Code
Tests should be clean, well-organized, and well-named just like the code they verify.
It should be easy to know what a test is doing. If the test requires setting up the environment, such as a session, then it can belong in a beforeEach, or setup function, at the top of the file. These functions set up things that are common for all tests.
However, avoid excessive use of beforeEach loops in tests. Identify what is truly important for the test. If it is code that is required to set up your data for the test, then it belongs in the test.
To reuse test setup across different tests, call properly-named functions that are explicit in their behavior. A test that calls a function “GetAValidUser” in the test setup is more readable than referencing a global user that is set up in some beforeEach loop, existing many lines away from the test.
Make sure that the test data is independent of other tests. Instead of returning an object from a helper function that is called and augmented in various tests, have the function return a new instance of the object. This avoids data integrity issues across tests, since changing the data in one test does not impact the object generated from the same helper function that is called in another test.
The time to deliver features increases when abstractions are confusing, hard to understand, and hard to extend. One needs to consider the tradeoffs between readability, maintainability, and performance when building abstractions.