While I've been progressing as a Software Engineer, there are milestone moments from insight you pick up from certain concepts, the area I feel I've gained a lot of this insight from is Design Patterns.
Design patterns are typical solutions to common problems in software design. Each pattern is like a blueprint that you can customize to solve a particular design problem in your code. (re: refactoring guru)
Refactoring guru is a great site for learning design patterns, I'm just going to touch on two of them and an application of them both - but if you're interested in learning more about them I would reccomend this site.
The design patterns we're going to explore and showcase as mentioned in the title is the Builder & Design Patterns which are creational patterns, i.e object creation.
Builder pattern is a way of using the methods you define on a class to build up what the final shape of the object should be.
And a director pattern is a way using the builders in a templated fashion; imagine a director telling people what to build, you can think of this class being opinionated whereas the builder isn't particularly.
The common example is building a car, as an exercise let's think of some high level components of a car (keep in mind I'm not a mechanic or automotive engineer).
Lets say we wanted to represent this as a natural way in code, the nice way of doing this is using classes, which generate objects.
class Car { public engine: Engine = new Engine(); public wheels: number = 0; public spoiler: boolean = false; public windows: number = 0; public doors: number = 0; public hasCupHolders: boolean = false; public hiddenMcdonaldsMonopolyThingsUnderSeat: boolean = false; public name: string = ''; } class Engine { public fast: boolean = false; public setFast () { this.fast = true; } }
Now we have a way of representing the components of a car (albeit not accurately).
Now, the magic of the builder pattern will allow us to "build" this in a nice, tidy, configurable way.
We need to define first now the CarBuilder class, which can compose these other classes together and provide options to the user of this builder through methods.
Some methods we can think off straight off the bat could be:
I guess if you fancy it you can stop here for a second and guess what the others will be and what would be passed into them.
The answer:
import { Engine, Window } from '@Car'; // You could also use DI here but let's not do that just yet. export class CarBuilder { private car: Car; public withFastEngine () { this.car.engine = new Engine().setFast(); return this; // So that you can chain these calls // new CarBuilder().withFastEngine().<this>.withSpoiler() } public withWheels (count: number) { this.car.wheels = count; return this; } public withSpoiler () { this.car.spoiler = true; return this; } public withWindows (count: number) { if (count === 0) { console.warn('Are you building a tank here?') } this.car.windows = count; return this; } public withDoors (count: number) { this.car.doors = count; return this; } public withCupHolders () { this.car.hasCupHolders = true; return this; } public giveName (name: string) { if (name.match(/Corsa/g)) { this.car.hiddenMcdonaldsMonopolyThingsUnderSeat = true; } this.car.name = name; return this; } public buildCar () { const finalCar = this.car; return finalCar; } }
And if we wanted to use this:
import { CarBuilder } from '@builders'; (() => { const car = new CarBuilder() .withWheels(3) .withSpoiler() .withWindows(4) .withDoors(2) .giveName('Sporty Reliant Robin') .buildCar(); /** * { engine { fast: true }, wheels: 2, spoiler: true; windows: 1, doors: 2, hasCupHolders: false, hiddenMcdonaldsMonopolyThingsUnderSeat: false, hasCupHolders: false; name: 'Sporty Reliant Robin'; * } */ })()
Now lets say we want to store this blueprint further, we can use a director pattern for that:
import { CarBuilder } from '@builders'; export class CarBuildingDirector { private carBuilder: CarBuilder = new CarBuilder(); public buildSportyReliantRobin () { return carBuilder .withWheels(3) .withSpoiler() .withWindows(4) .withDoors(2) .giveName('Sporty Reliant Robin') .buildCar(); } public buildLessSportyReliantRobin () { return carBuilder .withWheels(3) .withWindows(4) .withDoors(2) .giveName('Less Sporty Reliant Robin') .buildCar(); } }
And associated usage:
import { CarBuildingDirector } from '@director'; (() => { const car1 = new CarBuildingDirector().buildSportyReliantRobin(); const car2 = new CarBuildingDirector().buildLessSportyReliantRobin(); const race = new Race(car1, car2).race(); // some arbitrary class. console.log(race) // car 1 wins because of spoiler. })()
Hopefully that demonstration has given a bit of insight into the concept of a Builder and a Director pattern, you're probably thinking of the myriad of ways this can be applied because building stuff is what we do all the time - and this gives a nice representation of it within code.
Okay, now the example is done - let's move into applying this to what you probably came here for, applying this to unit tests and where the value sits there.
When you're working on a front end component, it might have dependencies on things such as a ThemeProvider, AuthProvider, ConfigProvider - something along those lines, you may have helpers for these such as:
const renderWithTheme = (child: ReactElement): RenderResult => { return render( <ThemeProvider> {child} </ThemeProvider> ) } // Usage: it('should render a button', () => { /** * <Button />: * * const Button = () => { * const theme = useTheme(); * * return <MUIButton theme={theme} /> * } */ const { getByTestId } = renderWithTheme(<Button />); const button = getByTestId('button') expect(button).toBeInDocument(); })
You may also come into cases where these need to be compounded together as you could have a component with many hooks and lots of consumer-provider dependencies.
Lets repeat the same pattern, lets think of what might consistute some of the provider dependencies of some arbitrary project, as before we mentioned:
And in a similar fashion lets turn this into a builder, we don't need to particularly have an initial class for this as we are building the RenderResult interface, which comes from @testing-library/react
, which is returned from called render(...)
.
// .tsx file & react-jsx export class TestRenderBuilder { private elementToBeRendered: ReactElement; constructor (elementToWrap: ReactElement) { this.elementToBeRendered = elementToWrap; } public withTheme () { /** * This is a safety step to prevent recursive * rereferencing (infinite loop). * * toBeRendered becomes what should be referenced. * -- * * This is mainly an issue if you use renderProps * and you pass children to a callback. (rough lesson) */ const toBeRendered = this.elementToBeRendered; this.elementToBeRendered = <ThemeProvider> {toBeRendered} </ThemeProvider> return this; } public withAuth (override?: Auth) { const toBeRendered = this.elementToBeRendered; this.elementToBeRendered = <Auth {...override}> {toBeRendered} </Auth> return this; } public withConfig (override?: Config) { const toBeRendered = this.elementToBeRendered; this.elementToBeRendered = <ConfigProvider {...override}> {toBeRendered} </ConfigProvider> return this; } public withContent (override?: ContentSchema) { const toBeRendered = this.elementToBeRendered; this.elementToBeRendered = <ContentProvider {...override}> {toBeRendered} </ContentProvider> return this; } public build (): RenderResult { return render( this.elementToBeRendered ); } }
With the usage:
import { TestRenderBuilder } from '@builder'; it('should render registration form welcome text from content hook', () => { const form = new TestRenderBuilder( <RegistrationForm locale="en" /> ) .withAuth() .withContent({ registration: { en: { welcome: 'Welcome to the form' } } }) .build(); const welcomeText = form.getByText('Welcome to the form'); expect(welcomeText).toBeInDocument(); })
You can see how from doing the initial leg work we have a nice configurable and reusable way of arranging our initial render for our tests.
We can also take this a step further and construct a Director to template some use cases - albeit this is totally optional; I primarily stick with a builder for this.
export class TestRenderDirector { private builder: TestRenderBuilder; constructor (builder: TestRenderBuilder) { this.builder = builder; // instanced builder passed in via DI. } buildHomePageDependencies () { return builder .withAuth() .withContent({ registration: { en: { welcome: 'Welcome to the form' }, aus: { welcome: 'ɯɹoɟ ǝɥʇ oʇ ǝɯoɔʃǝM' } } }) .withConfig({ bannerShowing: true, ads: false }) .withTheme() .build() } } // Usage: import { TestRenderDirector } from '@director'; it('should show home page', () => { const home = new TestRenderDirector( new TestRenderBuilder(<RegistrationForm locale="aus" />) ) .buildHomePageDependencies(); expect(home.getByText('ɯɹoɟ ǝɥʇ oʇ ǝɯoɔʃǝM')).toBeInDocument(); })
We now went from just defining what these were, getting an example of building a car, now we've applied it to building and directing our provider arranging for our tests.
Design patterns are powerful tools, they provide a plethora of tools that you can apply to your daily problems, if there's a good proven solution for something, use it, and you can learn a whole lot along the way.
By studying some design patterns - and then keeping them in the back of my mind I found this solution through necessity and then fell back on the builder pattern through relevance.
Don't forget about them, it's all fun trying to do run your brain trying to figure something complex out, but don't forget about design patterns.
Hopefully from this article you should have got a bit more exposure to:
If you read my article on abstracting rendering methods think of how you could combine it with this - I've done this before.
2022-10-27 - React |
---|
I've been looking into design patterns for a while, the builder pattern is one of them that I've used most often, and is very useful in the context of test arrangement. |
Think this would help others? |
|