Developing and Testing UI Components in Isolation
Unpopular opinion in the developer community is that writing UI code is plain and simple. Javascript is mostly known as “the ABC of web development,” and since mostly all popular UI frameworks are built upon it, many do not find frontend development challenging and often try to discredit it. Coming fresh out of college (before my first professional work experience), I was supporting that opinion too. Working as a full stack developer I have to state that my attitude back then was so wrong. We can agree that writing UI code in general can be plain and simple, but writing good UI code is challenging and hard…
When your codebase starts growing, many scenarios can give you a serious headache. Some of them will eventually show up on the road and you will have to navigate through the obstacles one at a time. In order to minimize future damage, we can start thinking ahead and find some concepts and good practices that should be introduced at the very beginning. One of those for sure is Developing Components in Isolation which will be discussed and demonstrated further.
Component-based development and benefits of development in isolation
One of the most important features of component-based development is reuse. Therefore, components should be developed in a way which is easily accessible, understandable, self-explanatory (documented) and (last but not least), testable. Developing components in isolation has various long-term benefits and makes our UI reliable. Our components mostly have many states in which they can end up, and we have to make sure that they behave as expected. Sometimes it can be hard (and boring) to end up in a wanted state for manual testing and even when we do, finding the root cause of our bugs can be a nightmare. If you ever wrote more than a couple lines of code, I am sure that you came across a situation where your bugfix “introduced” several other bugs. Regression is a side effect that we always have to be aware of when making code changes. Our more composite components can often communicate with API and exchange data. In various development phases, we can easily find ourselves in a situation where a specific endpoint our component needs is not implemented. Providing mock data is always a solution, but it can be time consuming to manually simulate every scenario and state which can happen down the road. Isolation is certainly not a silver bullet which leads to an immaculate UI, but it successfully overcomes all of the possible obstacles mentioned above.
Workflow
Let’s take a look at the following scenario when developing a new UI feature (e.g., page). You’ve been provided with the design files which describe the feature you are supposed to work on. Design examination has to be your starting point. After a detailed analysis of expected behavior and possible scenarios on a high level, it is suggested that you start looking for reusable units in order to de-structure your feature into components. Next in line should be development, followed by testing (or vice-versa in case of test-driven development) for all defined components. The presented scenario will be explained in the following example, which describes page development in isolation.
Design examination
As previously mentioned, the first step in the workflow should be identifying suitable component candidates. A new business requirement has been provided by the following design file.
Register page design
As we can immediately see, a page for registering new users should be developed. It is visible that the page consists of page title/header (arguably the header should be present for all pages with a dedicated title), and page body – form for user registration. After that, we can see that the form itself can be divided into smaller independent parts. It has a form title and a group of inputs (first name, last name, email and password), followed by a submit button and additional descriptions.
Register page components detection
From our previous conclusion it is visible that the required interface can be described and built with Page, Form, Input and Button components.
After basic component detection, we should consider user actions and possible scenarios and flows. Entered form data should be validated and the user should be provided a clear and descriptive response after form submission. Describe scenarios following design files (with defined components/parts) for validation of success and failure. Both scenarios include a popup which notifies the user about form data submission status.
Register page – form submission success (component detection)
From the past two screen mockups, we can see that a Popup component should be developed in order to display feedback to the user. A popup component consists of a popup title, content and two action buttons (cancel and ok). The Button component mentioned earlier can be reused for displaying dedicated action buttons.
Development in isolation
After previously discussing basics of developing in isolation and examining given design, now we can tackle the usage of Storybook for these purposes. Storybook is an open-source tool used for building UI components in isolation. Besides component development, the tool can also serve as code documentation and it can even be used for testing purposes. A Story represents a component rendered in a particular state and it is used to demonstrate standard use cases, as well edge cases that a particular component can end up in. In this blog, we won’t go into Storybook setup details, as those are explained pretty well in the official docs. We will use Storybook for React to develop our components in isolation.
The first candidate that comes to mind is the basic Input component, since we’ve seen that it will be used as a part of more complex components. Even though its logic is not complex at all, it covers Storybook’s key concepts. From the given design we can notice that two different states will be needed for the Input component: Text and Password – therefore two separate stories will be created for this component.
const Input = ({ name, className, type, label, required, pattern }) => { const { formValues, setFormValues, fieldsValidationResult, validateSingleField } = useContext(FormContext); const hasError = !!fieldsValidationResult[name]?.length; const handleInputChange = (e) => { const { value } = e.target; setFormValues({ ...formValues, [name]: value }); validateSingleField({ name, required, pattern }, true, value); } return ( <div className={classNames('c-input' , { className: className })}> {label && <label className='c-label'> {label} </label>} <input className={classNames('c-input-field' , { 'c-input-field--error': hasError })} type={type} onChange={handleInputChange} required={required} /> {hasError && <ErrorMessage message={fieldsValidationResult[name]}/> } </div> ) }
Input.jsx
The Input component is a custom HTML <input> wrapper with predefined props such as: name prop for specifying field name inside FormContext, className, type (text, email, etc.), label (rendered above input element), and validation props – required and pattern. Input field validates its data (handleInputChange) based on provided props and returns new value alongside validation result to dedicated state variables inside FormContext (shared state – React Context on form level). Input field errors are displayed inside a simple ErrorMessage component. Stories for Input are written as follows.
export default { title: 'Components/Input', component: Input, decorators: [FormWidthDecorator] }; const Template = (args) => <Input {...args} />; export const Text = Template.bind({}); Text.args = { type: 'text', . . . } export const Password = Template.bind({}); Password.args = { type: 'password', . . . }
Input.stories.jsx
FormWidthDecorator is a simple decorator used to set min and max width for our story content. Template is used to declare a common story template later exploited by providing props in order to get the component to a variety of different states. Given example shows prop type being provided to indicate input type. Other passed props are the same in both cases, therefore they are not shown in the snippet.
Now, we can start our Storybook server with npm run storybook and check out our first written stories at port 6006 (by default). The result is shown below. After that, we can focus on developing other more complex components. It is worth noting that stories for given components will not be shown since their implementation is trivial.
Text Input Story
Password Input Story
Next is the Form component. Children in our case is an array of Input fields, used to specify which fields will form contain.
const Form = ({ title, onSubmit, children }) => { ... return ( <div className='c-form-wrapper'> <span className='c-form-title'> {title.toUpperCase()} </span> <FormContext.Provider value={{ formValues, setFormValues, fieldsValidationResult, setFieldsValidationResult, validateSingleField }} > <div className='c-form'> {children} <Button label={title} className='c-form-submit' onClick={submitIfPossible} onClickCapture={validateFields} type={BUTTON_TYPES.SUBMIT} /> {actionComponent} </div> </FormContext.Provider> </div> ) }
Form.jsx
Form component is furthermore wrapped inside RegisterForm which contains all input fields later rendered as children.
const RegisterForm = ({ onSubmit }) => { const children = [ <Input key={getRandomId()} name='firstName' type='text' label='First Name' required />, <Input key={getRandomId()} name='lastName' type='text' label='Last Name' required />, <Input key={getRandomId()} name='email' type='text' label='Email' required pattern={EMAIL_PATTERN} />, <Input key={getRandomId()} name='password' type='password' label='Password' required /> ]; return ( <Form title='Register' onSubmit={onSubmit} children={children} /> ) }
RegisterForm.jsx
Now, let’s take a look at the Popup component. Code and explanation for Page and Button components will be omitted for the sake of simplicity. It is worth noting (in the scope of Popup component), that Page component is the root that provides PopupContext used for displaying feedback to the user.
const Popup = ({ title, content, confirmAction, confirmLabel, cancelAction, cancelLabel, visible, type }) => { const { popupProps, setPopupProps } = useContext(PopupContext); const dismissPopup = () => setPopupProps({ ...popupProps, visible: false }); return ( <div className={classNames("c-popup-wrapper", { "c-hidden": !visible })}> <div className="c-popup"> <span className="c-popup-title"> {title?.toUpperCase()} </span> <div className="c-popup-content"> {POPUP_DISPLAY_ICONS[type]} {content} </div> <div className="c-popup-actions"> <Button label={cancelLabel || 'Cancel'} onClick={cancelAction || dismissPopup} type={BUTTON_TYPES.SECONDARY} /> <Button label={confirmLabel || 'Ok'} onClick={confirmAction || dismissPopup} type={BUTTON_TYPES.PRIMARY} /> </div> </div> </div> ) };
Popup.jsx
Popup props include title, type (success or warning), content and actions (confirm and cancel) – alongside dedicated labels.
Now, let’s develop the Register component.
const Register = () => { const { setPopupProps } = useContext(PopupContext); const formSubmit = (values) => { registerUser(values).then(res => { res.status === HTTP_OK && setPopupProps({ title: 'Register', content: <span><b> {`Hello, ${values.firstName} ${values.lastName}!`} </b> {`\n\nYou are successfully registered! Check out your email: ${values.email} for more instructions.`} </span>, visible: true, type: POPUP_TYPES.SUCCESS }); }).catch(_ => setPopupProps({ title: 'Error', content: <span><b> {'An error has occurred.'} </b> {'\n\nPlease check entered values and try again.'} </span>, visible: true, type: POPUP_TYPES.WARNING })); } return <RegisterForm onSubmit={formSubmit} />
Register.jsx
After that, we can provide a dedicated story for the Register page which will be later used for automated testing. We will build a decorator (wrapper) named PopupContextDecorator which will hold necessary context variables that are later consumed in the Register component.
export const PopupContextDecorator = (Story) => { const [popupProps, setPopupProps] = useState({}); return ( <PopupContext.Provider value={{ popupProps, setPopupProps }}> <Popup {...popupProps} /> <Story /> </PopupContext.Provider> ); }
PopupContextDecorator.stories.jsx
Register story is written in standard form with included decorator which we’ve previously discussed.
export default { title: "Pages/Register", component: Register, decorators: [PopupContextDecorator] } const Template = (args) => <Register {...args} /> export const Standard = Template.bind({});
Register.stories.jsx
Now, an isolated Register page is being served as a single story inside Storybook and we are good to go.
Register page – Storybook
Testing in isolation
We’ve finally come to a place where we can tackle testing our isolated components. A useful tool which can be used for that purpose combined with previously explained Storybook is Cypress. It is mostly used for end-to-end, integration, and unit testing. We won’t go into details considering Cypress setup, but one thing is worth noting. In order to test our stories, we should configure Cypress to visit our running Storybook instance (instead of the actual app). With that being said, let’s set our baseUrl to http://localhost:6006 (by default our Storybook url).
With that, we can concentrate on testing the Register page in a scenario where user registration succeeds (explained during component investigation). Let’s first configure basic actions in the test file which will occur before each test run. For now, we are going to visit our story and capture form fields using aliases.
describe('tests for register page in isolation', () => { beforeEach(() => { cy.visit('iframe.html?id=pages-register--standard') .get(':nth-child(1) > .c-input-field').as('firstName') .get(':nth-child(2) > .c-input-field').as('lastName') .get(':nth-child(3) > .c-input-field').as('email') .get(':nth-child(4) > .c-input-field').as('password') .get('.c-button-submit').as('submitButton'); }); . . .
Then, since Register component triggers API call (registerUser) we can use cy.intercept to mock that request and provide appropriate response. Using statusCode: 200 indicates successful response.
describe('tests when submit is successful', () => { beforeEach(() => { cy .intercept('POST', '**/users', { statusCode: 200 }).as('registerUser') });
After that, we can write our first test. Cypress will first visit our story and then try to simulate the end user filling out the form with violating several validation rules. When all fields are validated, form submission will trigger an API call (already mocked as shown) which results in displaying a success popup whose content will be examined.
it('should submit successfully when provided correct values', () => { cy .get('@firstName') .type('John') .get('@lastName') .type('Doe') .get('@email') .type('jdoe.com') .get('@password') .type('examplePassword') .get('.c-input-field--error-message') .should('be.visible') .should('contain.text', VALIDATION_MESSAGES[EMAIL_PATTERN]) .get('@email') .clear() .type('[email protected]') .get('@firstName') .clear() .get('.c-input-field--error-message') .should('be.visible') .should('contain.text', VALIDATION_MESSAGES.GENERIC) .get('@firstName') .type('Jane') .get('@submitButton') .wait(1000) .click() .get('.c-popup-title') .should('be.visible') .should('contain.text', 'REGISTER') .get('.c-popup-content') .should('be.visible') .should('contain.text', 'Hello, Jane Doe') .get('.c-button-primary') .wait(1000) .click() .get('.c-popup-title') .should('not.be.visible'); }) })
register-page-test.spec.js
Cypress provides intuitive and useful UI demonstrations of test runs. Using npx cypress open leads us to the tool’s interface where we can manually or automatically run our tests. Running previously written tests result as follows.
Cypress tests in action
[BONUS] Code Coverage with Cypress
When writing tests, we want to make sure that all common use cases (and the majority of other possible scenarios) are fully covered. Cypress offers a possibility to keep track of your code coverage with practices established at Code Coverage with Cypress. Percentage of covered code is calculated using instrumented code instead of original/raw code. The tool itself does not instrument the code and we will have to do it ourselves. Fortunately, Istanbul.js tool provides us the way for doing it and there are two possibilities: manual instrumentation (using nyc module) and using a Babel plugin named babel-plugin-istanbul. Let’s choose the second method for the sake of simplicity. After installing the mentioned plugin, we simply have to add it in Storybook’s main.js as follows.
"babel": async (options) => ({ ...options, plugins: ["istanbul"] }),
Storybook config for babel-plugin-istanbul
Now we are good to go. Inside the coverage folder found in the root directory, an HTML report gets generated each time we run our tests. Opening it gives us the following (note – more tests have been added after one mentioned above).
Code coverage summary
Besides code coverage summary, let’s take a look at the coverage for a single component. As an example, we can use the Input component.
Code coverage for Input component
The given report is self-explanatory and it is clear that the Input component is fully covered with tests (including all statements, branches and functions).
Conclusion
In this article we have focused on developing and testing UI components in isolation and various benefits of the mentioned approach. Storybook for React was used as a tool for component developing and Cypress alongside Istanbul.js coverage tool for testing. Products of a given workflow are documented, easily understandable and testable components which can easily scale and adjust to new requirements. I hope that you find stated practices and concepts useful and applicable in your projects.