Front end can’t TDD/front end is not easy to do TDD/front end TDD is not profitable

This is the “death sentence” that countless people gave me after entering the company.

In fact, it seems so, right?

In this agile organization, we have pre-graduate training, post-graduate training, TwU, and countless regular and irregular training. The topic of TDD runs through and is the main battleground of almost every training.

Our enemies in this field are private FizzBuzz, captain MarsRover, ace ParkingLot. But all the enemies seem to have no face, just lines of logic and command line output. The front of the enemy seemed to be full of recruits who had never been to battle. Let’s go in with the rest, shawls and thorns. To defeat every enemy there is one question in my mind:

How does the front end TDD?

It’s all the same.

That’s the answer I get from every coach at almost every training, and every time I’m like – Oh, same thing, that’s okay.

But every time I go on a project and practice it, I get a little bit of a frown and realize that it’s not that simple – it’s as if everyone is ignoring the various logic and UI coupling at the front end that makes your single test less ununit. The old man on the project will tell you, “The front end is TDD pure logic.”

Yes, those utils and those very utils like things.

From TDD to test methods

In computer programming, Unit Testing, also known as module Testing, is the test work for the correctness check of the program module (the smallest Unit of software design). A program unit is the smallest testable part of an application. In procedural programming, a unit is a single program, function, procedure, etc. For object-oriented programming, the smallest unit is a method, including methods in a base class (superclass), an abstract class, or a derived class (subclass).

This is a long explanation from wiki, where you can see that the goal of a unit test is the smallest logical unit module, which is either an independent function or a method in an object.

From a unit testing point of view, the way to do TDD on the front end suddenly becomes clear – find the boundaries – find the boundaries between these functions and methods and the UI.

boundary

Take the component as the boundary

Whether for testing rationality or other reasons, front-end framework officials have launched a series of testing-libraries. The idea is to initialize a component’s parameters, render the component into an environment, and then simulate the user’s page behavior in that environment. Finally, select some specific elements to assert that the component is behaving correctly.

As mentioned earlier, the difficulty of front-end TDD is the coupling of UI and logic. This solution simply ignores the coupling. The entire component is tested by simulating user actions to assert that the rendered UI has an action/text/node.

The boundary at this point will be the entire component.

Such a solution does address the pain points of bad/not-so-good TDD, but it poses some problems:

  1. The last thing to do is to render the component and to simulate user behavior and wait for the component to respond. This is significantly more time consuming than purely logical testing, especially as the project grows larger and the number of tests exceeds 2000 or so, seriously undermining the need for quick feedback in unit testing;

  2. Unit tests, but this is not a “unit” test, everything is tested in one test (action/Reducer/saga – if you have one), even you need to write some simple logic to simulate user events in the test. The tests that end up being written often involve numerous user actions and assertions, more like an integration test or even an E2E test;

  3. You need to write selectors to select specific elements, which means that in some cases you will need to add selectable attributes to elements of production code for “unit testing”, such as test-id/ID, etc.

Boundary with component logic

If you’re using three big frameworks, then your UI ends up being rendered by a set of templates, and the data in the template is computed from somewhere else, and if we can find the boundary between the template and this place, we’ve actually found the boundary between UI and logic, If we uncouple the UI and logic at this point, we can actually test all the logic instead of the pure logic, and TDD is really the same as the back end.

The boundary at this point will be the logic within the component.

Very abstract? Let’s do an example:

In the React function component, the return statement contains the template for rendering the UI. Therefore, the return statement is a boundary. The upper part of the return statement is the logic and the lower part is the template.

– Any hooks are side effects. All pure functions without side effects should be stored somewhere in a separate way and then imported into the current component to be called by means of import. We calculated the data needed for the template using a combination of hooks and pure functions, and finally put all the calculation logic into a hook as a side effect of the functional component.

So our component has a clear boundary — pass in the props needed to render the component, pass in the props to the hook as the input value to calculate and export the output back to the component, and pass the output to the template for rendering.

Are you TDD to hooks?

But is this border demarcation ok?

  1. The premise of doing this is that everyone puts all the logic in one place, but people always want to be lazy. At this time, the team needs to approve this method and strictly implement code review to ensure that no one is lazy.

  2. Some of the logic that would be silly to pull out of a template, such as whether to render a Node or different nodes based on a value, falls into the same logic, and the function/method returns a Node.

The problem

Some might argue that the second option leaves out component feedback on user actions. For example, the behavior of a component when a button is clicked.

But what I want to say is that the feedback generated by clicking a button is essentially an event listener and a callback, and the callback will eventually call a function, and the second scheme ensures that the function behaves correctly.

As to whether clicking the button actually calls the callback function, as long as you use the framework syntax correctly, that should be the framework’s job, and I choose to trust the framework code. As for using syntax incorrectly, first of all, your editor should give you an error, second of all, it’s theoretically infrequent, and third of all, it’s not something that unit tests should deal with.

And why? I’d like to start with the test pyramid.

Test the Pyramids

test-pyramid

The test pyramid encourages us to divide all automated tests into different granularity, and different tests should focus on different scenarios.

I have seen many students’ obsession with unit testing is that they want to cover every corner of their code with unit tests, exhaustive every behavior in their code, and ensure that every line of code is correct. This is not so much writing tests as pursuing test coverage.

Testing should be about boundaries. What happens inside the boundary doesn’t matter what happens outside the boundary, just what happens inside the boundary is correct. Unit tests don’t need to care about what happens outside of your own unit, and how well two or more units work together should be left to integration tests.

Integration testing

Going back to the previous section, if we think of logic and THE UI as two units, then the correct integration between them (such as the correct button click) should be left to integration testing, not unit testing, which in this case is component testing.

The various testing- libraries mentioned earlier are perfect for doing just that – rendering components, simulating user clicks, and finally asserting that the component behaves correctly if the click event is captured.

Having said that, I don’t recommend using it for component testing. I recommend using Cypress. In Cypress 7.0, it introduces the Component testing Runner, which simply renders components to browsers instead of traditional JsDOM based on Node. It also helps developers circumvent the anti-patterns of many component tests.

UI test

At this point we have been able to ensure that the logic of each component is what we expect, and we certainly believe that the UI rendered from the Template will be what we expect when that logic is computed. But we didn’t have a test to give us enough confidence, so we needed UI testing to make sure we were rendering the same page every time.

One of the problems here is that if a page is still in the iterative phase, then if we add UI tests to the page at this point, the test will fail frequently with frequent page changes, and we will need to fix the test frequently.

If a project has high requirements for the correctness of the UI, to ensure the correctness of the rendering, we can break up a page, add UI tests to the completed sections first, and then add the final page tests after the whole page is completed. If a project doesn’t require that much correctness from the UI, then just wait for the page to be developed and then add UI testing.

E2E test

After the development of a certain user journey of an APP (such as User login) is completed, we can add an E2E test to the user Journey to ensure its integrity and prevent it from being damaged in subsequent development. Mimic what users would do if they actually used the APP.

It should be noted that the E2E test requires various parts of the APP, which is resource-consuming and time-consuming, so we should use the E2E test as little as possible. Theoretically, each user journey only needs a happy path and an unhappy path.

What are tests written for

test

In fact, our bottom-up testing strategy has already been outlined. Due to space constraints, I cannot discuss each type of testing pyramid in detail in the same article. What I want to express here is:

  1. Unit tests shouldn’t be all-encompassing, so don’t focus too much on test coverage

  2. Tests of different granularity should focus on different points that are complementary to each other

  3. TDD on the front can be just as smooth as the back under certain conditions

Finally, testing shouldn’t be a burden, it shouldn’t be a KPI, it shouldn’t be part of QA. It is an integral part of ensuring software quality, and TDD, which goes further than testing, gives us enough courage to modify old code. I sincerely hope that everyone can gradually realize these points and accept them.

I wish you all a happy New Year