DEV Community

Cover image for Creating a React Custom Hook using TDD
Matti Bar-Zeev
Matti Bar-Zeev

Posted on

Creating a React Custom Hook using TDD

In this post join me as I create a React Custom Hook which will encapsulate the logic behind a simple Pagination component.

A Pagination component is a component which lets the users navigate between content “pages”. The users can move up and down the list of pages but also have the ability to go directly to a page they desire, something of this sort:

Image description

(Image taken from material UI)

I’m starting from the list of requirements for this hook:

  • It should receive a total pages number
  • It can receive and initial cursor, but if it didn’t the initial cursor is the first index
  • It should return the following:
    • The total pages count
    • The current cursor position
    • A goNext() method for getting to the next page
    • A goPrev() method for getting to the previous page
    • A setCursor() method to set the cursor to a specific index
  • If an “onChange” callback handler is passed to the hook it will be invoked when the cursor changes with the current cursor position as an argument

I’m creating 2 files: UsePagination.js which will be my custom hook and UsePagination.test.js which will be my test for it. I launch Jest in watch mode and dive in.

For testing the hook logic I will be using the react-hooks-testing-library which allows me to test my hook without having to wrap it with a component. Makes the tests a lot more easy to maintain and focused.

First of all, let's make sure that there is a UsePagination custom hook:

import {renderHook, act} from '@testing-library/react-hooks';
import usePagination from './UsePagination';

describe('UsePagination hook', () => {
   it('should exist', () => {
       const result = usePagination();
       expect(result).toBeDefined();
   });
});
Enter fullscreen mode Exit fullscreen mode

Our test fails of course. I will write the minimal code to satisfy it.

const usePagination = () => {
   return {};
};

export default usePagination;
Enter fullscreen mode Exit fullscreen mode

I am not testing with the react-hooks-testing-library yet, since I don’t have a need for that yet. Also remember, I’m writing the minimal code to make my tests pass and that’s it.

Ok, moving forward I would like to test the first requirement. I realize that the hook cannot work if no total pages were given to it, so I’d like to throw an error if no total pages number were given to it. Let’s test that:

it('should throw if no total pages were given to it', () => {
       expect(() => {
           usePagination();
       }).toThrow('The UsePagination hook must receive a totalPages argument for it to work');
   });
Enter fullscreen mode Exit fullscreen mode

No error is thrown at the moment. I will add it to the hook’s code. I decide that the hook will receive it’s args in an object format, and so:

const usePagination = ({totalPages} = {}) => {
   if (!totalPages) {
       throw new Error('The UsePagination hook must receive a totalPages argument for it to work');
   }
   return {};
};

export default usePagination;
Enter fullscreen mode Exit fullscreen mode

The tests run but something is wrong. The first test I wrote fails now because I didn’t pass totalPages for it and now it throws. I will fix that:

it('should exist', () => {
       const result = usePagination({totalPages: 10});
       expect(result).toBeDefined();
   });
Enter fullscreen mode Exit fullscreen mode

Great. Now let’s refactor a bit. I don’t like this error string written like that instead of a constant I can share and make sure that the test is always aligned with the hook. The refactor is easy:

export const NO_TOTAL_PAGES_ERROR = 'The UsePagination hook must receive a totalPages argument for it to work';

const usePagination = ({totalPages} = {}) => {
   if (!totalPages) {
       throw new Error(NO_TOTAL_PAGES_ERROR);
   }
   return {};
};

export default usePagination;
Enter fullscreen mode Exit fullscreen mode

And my test can use it:

import usePagination, {NO_TOTAL_PAGES_ERROR} from './UsePagination';

describe('UsePagination hook', () => {
   it('should exist', () => {
       const result = usePagination({totalPages: 10});
       expect(result).toBeDefined();
   });

   it('should throw if no total pages were given to it', () => {
       expect(() => {
           usePagination();
       }).toThrow(NO_TOTAL_PAGES_ERROR);
   });
});
Enter fullscreen mode Exit fullscreen mode

Are there any other mandatory args to be validated? Nope, I think this is it.

Moving on I would like to test that the hook returns the totalPages back. Here I start to use the renerHook method to make sure my hooks acts as it would in the “real world”:

it('should return the totalPages that was given to it', () => {
       const {result} = renderHook(() => usePagination({totalPages: 10}));
       expect(result.current.totalPages).toEqual(10);
   });
Enter fullscreen mode Exit fullscreen mode

The test fails and so we write the code to fix that:

const usePagination = ({totalPages} = {}) => {
   if (!totalPages) {
       throw new Error(NO_TOTAL_PAGES_ERROR);
   }
   return {totalPages};
};
Enter fullscreen mode Exit fullscreen mode

NOTE: I jumped a step here since the minimal code to satisfy the test would be returning a hard coded 10 as the totalPages, but it is redundant in this case since the logic here is really straightforward.

Now I would like to check that the hook returns the current cursor position. I will start with the requirement of “if it did not receive a cursor position as an arg, it should initialize it as 0”:

it('should return 0 as the cursor position if no cursor was given to it
', () => {
       const {result} = renderHook(() => usePagination({totalPages: 10}));
       expect(result.current.cursor).toEqual(0);
   });
Enter fullscreen mode Exit fullscreen mode

The code for passing this test is simple. I will return a hard coded 0 as the cursor from the hook ;)

const usePagination = ({totalPages} = {}) => {
   if (!totalPages) {
       throw new Error(NO_TOTAL_PAGES_ERROR);
   }
   return {totalPages, cursor: 0};
};
Enter fullscreen mode Exit fullscreen mode

But we have another requirement which is “when the hook receives a cursor it should return that, and not the default value”:

it('should return the received cursor position if it was given to it', () => {
       const {result} = renderHook(() => usePagination({totalPages: 10, cursor: 5}));
       expect(result.current.cursor).toEqual(5);
   });
Enter fullscreen mode Exit fullscreen mode

Obviously the test fails since we are returning a hardcoded 0. This is how I tweak the code to make it pass:

const usePagination = ({totalPages, cursor} = {}) => {
   if (!totalPages) {
       throw new Error(NO_TOTAL_PAGES_ERROR);
   }

   cursor = cursor || 0;

   return {totalPages, cursor};
};
Enter fullscreen mode Exit fullscreen mode

Good for now.

The hook has to return a few methods. For now we will only test that it does return these methods with no intention of invoking them:

it('should return the hooks methods', () => {
       const {result} = renderHook(() => usePagination({totalPages: 10}));
       expect(typeof result.current.goNext).toEqual('function');
       expect(typeof result.current.goPrev).toEqual('function');
       expect(typeof result.current.setCursor).toEqual('function');
   });
Enter fullscreen mode Exit fullscreen mode

And the code to satisfy it:

const usePagination = ({totalPages, cursor} = {}) => {
   if (!totalPages) {
       throw new Error(NO_TOTAL_PAGES_ERROR);
   }

   cursor = cursor || 0;

   const goNext = () => {};

   const goPrev = () => {};

   const setCursor = () => {};

   return {totalPages, cursor, goNext, goPrev, setCursor};
};
Enter fullscreen mode Exit fullscreen mode

The scaffold for our custom hook is ready. Now I need to start adding the hook’s logic into it.

I will start with the simplest bit of logic which is setting the cursor by using the setCursor method. I would like to invoke it and check that the cursor really changed. I simulate how React runs in the browser by wrapping the action I’m checking with the act() method:

describe('setCursor method', () => {
       it('should set the hooks cursor to the given value
', () => {
           const {result} = renderHook(() => usePagination({totalPages: 10}));

           act(() => {
               result.current.setCursor(4);
           });

           expect(result.current.cursor).toEqual(4);
       });
   });
Enter fullscreen mode Exit fullscreen mode

NOTE: I created it in a nested “describe” for better order and readability.

And the test fails! If I try to do something naive like setting the cursor value on the hook's setCursor exposed method it still does not work since my hook fails to persist this value. We need some stateful code here :)
I will use the useState hook in order to create a cursor state for the hook:

const usePagination = ({totalPages, initialCursor} = {}) => {
   if (!totalPages) {
       throw new Error(NO_TOTAL_PAGES_ERROR);
   }

   const [cursor, setCursor] = useState(initialCursor || 0);

   const goNext = () => {};

   const goPrev = () => {};

   return {totalPages, cursor, goNext, goPrev, setCursor};
};
Enter fullscreen mode Exit fullscreen mode

This requires some explanations - first of all I changed the cursor arg name to initialCursor so that it won’t conflict with the useState returned variable. Second, I removed my own setCursor method and exposed the setCursor method returning from the useState hook.

Running the tests again and while the last one passes, both the first and fifth fail. The fifth fails because I’m passing “cursor” and not “initialCursor”, while the first one fails over “Invalid hook call. Hooks can only be called inside of the body of a function component” so we need to wrap it with renderHook(), and now it looks like this:

it('should exist', () => {
       const {result} = renderHook(() => usePagination({totalPages: 10}));
       expect(result.current).toBeDefined();
   });
Enter fullscreen mode Exit fullscreen mode

On top of that, let's add a test which checks that we cannot set a cursor which is outside the boundaries of the total pages count. Here are 2 tests which check that:

it('should not set the hooks cursor if the given value is above the total pages', () => {
           const {result} = renderHook(() => usePagination({totalPages: 10}));

           act(() => {
               result.current.setCursor(15);
           });

           expect(result.current.cursor).toEqual(0);
       });

it('should not set the hooks cursor if the given value is lower than 0', () => {
           const {result} = renderHook(() => usePagination({totalPages: 10}));

           act(() => {
               result.current.setCursor(-3);
           });

           expect(result.current.cursor).toEqual(0);
       });
Enter fullscreen mode Exit fullscreen mode

Wow… The challenge here is that the useState does not allow me to run some logic in the setCursor method it returns. What I can do is to convert it to the useReducer hook. This kinda cancels what I recently did with the setCursor method, as the code evolves:

const SET_CURSOR_ACTION = 'setCursorAction';
...

const [cursor, dispatch] = useReducer(reducer, initialCursor || 0);

   const setCursor = (value) => {
       dispatch({value, totalPages});
   };
Enter fullscreen mode Exit fullscreen mode

And My reducer function is external to the hook function like so (don’t worry, I will paste the entire code at the bottom of the post):

function reducer(state, action) {
   let result = state;

   if (action.value > 0 && action.value < action.totalPages) {
       result = action.value;
   }

   return result;
}
Enter fullscreen mode Exit fullscreen mode

I have no cases here so there is no real need for a switch-case statement.
Nice. All tests pass so we can move on.

Next is the goNext() method exposed from the hook. I would like to see it moving to the next cursor position first:

describe('goNext method', () => {
       it('should set the hooks cursor to the next value', () => {
           const {result} = renderHook(() => usePagination({totalPages: 2}));

           act(() => {
               result.current.goNext();
           });

           expect(result.current.cursor).toEqual(1);
       });
   });
Enter fullscreen mode Exit fullscreen mode

And here is the code to make it pass:

const goNext = () => {
       const nextCursor = cursor + 1;
       setCursor(nextCursor);
   };
Enter fullscreen mode Exit fullscreen mode

But that’s not the end of it. I would like to make sure that when we reach the last page, goNext() will have no effect on the cursor position anymore. Here is the test for it:

it('should not set the hooks cursor to the next value if we reached the last page', () => {
           const {result} = renderHook(() => usePagination({totalPages: 5, initialCursor: 4}));

           act(() => {
               result.current.goNext();
           });

           expect(result.current.cursor).toEqual(4);
       });
Enter fullscreen mode Exit fullscreen mode

Gladly for me the logic inside the state reducer takes care of that :)
I will do the same for the goPrev method.

Ok, so we got these 2 methods covered, now we would like to implement the callback handler feature of the hook. When we pass a callback handler to the hook, it should be invoked when the cursor changes, be it by moving next/prev or set explicitly.
Here is the test for it:

describe('onChange callback handler', () => {
       it('should be invoked when the cursor changes by setCursor method', () => {
           const onChangeSpy = jest.fn();
           const {result} = renderHook(() => usePagination({totalPages: 5, onChange: onChangeSpy}));

           act(() => {
               result.current.setCursor(3);
           });

           expect(onChangeSpy).toHaveBeenCalledWith(3);
       });
   });
Enter fullscreen mode Exit fullscreen mode

For that I will use the useEffect hook in order to monitor over changes in the cursor state and when they happen and a callback is defined the hook will invoke it with the current cursor as the argument:

useEffect(() => {
       onChange?.(cursor);
   }, [cursor]);
Enter fullscreen mode Exit fullscreen mode

But we’re not done. I suspect that the callback handler will be called when the hook initializes as well and this is wrong. I will add a test to make sure it does not happen:

it('should not be invoked when the hook is initialized', () => {
           const onChangeSpy = jest.fn();
           renderHook(() => usePagination({totalPages: 5, onChange: onChangeSpy}));

           expect(onChangeSpy).not.toHaveBeenCalled();
       });
Enter fullscreen mode Exit fullscreen mode

As I suspected, the test fails. For making sure the onChange handler is not called when the hook initializes I will use a flag which indicates whether the hook is initializing or not, and invoke the handler only when it is not. In order to persist it across renders but not force a new render when it changes (like with state) I will use the useRef hook:

const isHookInitializing = useRef(true);

   useEffect(() => {
       if (isHookInitializing.current) {
           isHookInitializing.current = false;
       } else {
           onChange?.(cursor);
       }
   }, [cursor]);
Enter fullscreen mode Exit fullscreen mode

And there we have it. A custom hook which was fully created using TDD :)

Challenge yourself - see if you can implement a cyclic mode for the pagination (for instance, once it reaches the end it goes back to the beginning) using TDD 🤓

Here is the full hook code:

import {useEffect, useReducer, useRef, useState} from 'react';

export const NO_TOTAL_PAGES_ERROR = 'The UsePagination hook must receive a totalPages argument for it to work';

const usePagination = ({totalPages, initialCursor, onChange} = {}) => {
    if (!totalPages) {
        throw new Error(NO_TOTAL_PAGES_ERROR);
    }

    const [cursor, dispatch] = useReducer(reducer, initialCursor || 0);

    const setCursor = (value) => {
        dispatch({value, totalPages});
    };

    const goNext = () => {
        const nextCursor = cursor + 1;
        setCursor(nextCursor);
    };

    const goPrev = () => {
        const prevCursor = cursor - 1;
        setCursor(prevCursor);
    };

    const isHookInitializing = useRef(true);

    useEffect(() => {
        if (isHookInitializing.current) {
            isHookInitializing.current = false;
        } else {
            onChange?.(cursor);
        }
    }, [cursor]);

    return {totalPages, cursor, goNext, goPrev, setCursor};
};

function reducer(state, action) {
    let result = state;

    if (action.value > 0 && action.value < action.totalPages) {
        result = action.value;
    }

    return result;
}

export default usePagination;
Enter fullscreen mode Exit fullscreen mode

As always, if you have any ideas on how to make this better or any other technique, be sure to share with the rest of us!

Cheers

Hey! If you liked what you've just read check out @mattibarzeev on Twitter 🍻

Photo by Todd Quackenbush on Unsplash

Discussion (11)

Collapse
roblevintennis profile image
Rob Levin • Edited on

I really liked the approach of this article using TDD and also enjoyed working with the react-hooks helpers. I referenced a bunch of various pagination tutorials and gists on the web and even old stuff I'd written a while back to find a solution I liked. I followed along mostly similar to what you have here but then completely tore down the entire design. I realized that, in my design, usePagination hook only needed to take care of generating the pagination links and nothing else. Totally SRP cohesive. Then, the React component would deal with pretty much only rendering the pagination controls. The consumer would, in fact, take care of the const [currentPage, setCurrentPage] state and simply listen for onPageChanged callback, update the current page, then have a listener in useEffect that would regenerate the paging links based off of the updated current page. There's a lot of code between the three, but I can show the tests for my usePagination as I think you'll find it interesting that it's basically a completely different approach!

import { renderHook, act } from '@testing-library/react-hooks';
import { usePagination } from './usePagination';

describe('generate paging', () => {
  it('should work for smaller totals', () => {
    const { result } = renderHook(() => usePagination({ offset: 2 }));
    act(() => {
      expect(result.current.generate(1, 4)).toStrictEqual([1, 2, 3, 4]);
      expect(result.current.generate(1, 5)).toStrictEqual([1, 2, 3, 4, 5]);
      expect(result.current.generate(1, 6)).toStrictEqual([1, 2, 3, '...', 6]);
      expect(result.current.generate(5, 6)).toStrictEqual([1, 2, 3, 4, 5, 6]);
      expect(result.current.generate(5, 7)).toStrictEqual([1, 2, 3, 4, 5, 6, 7]);
      expect(result.current.generate(6, 8)).toStrictEqual([1, '...', 4, 5, 6, 7, 8]);
    });
  });
  it('should generate pagination with offset of 2', () => {
    const { result } = renderHook(() => usePagination({ offset: 2 }));
    act(() => {
      // Edge case only 1 total pages
      expect(result.current.generate(10, 1)).toEqual([1]);
      expect(result.current.generate(1, 20)).toEqual([1, 2, 3, '...', 20]);
      expect(result.current.generate(2, 20)).toEqual([1, 2, 3, 4, '...', 20]);
      expect(result.current.generate(3, 20)).toEqual([1, 2, 3, 4, 5, '...', 20]);
      expect(result.current.generate(4, 20)).toEqual([1, 2, 3, 4, 5, 6, '...', 20]);
      expect(result.current.generate(5, 20)).toEqual([1, 2, 3, 4, 5, 6, 7, '...', 20]);
      expect(result.current.generate(6, 20)).toEqual([1, '...', 4, 5, 6, 7, 8, '...', 20]);
      expect(result.current.generate(7, 20)).toEqual([1, '...', 5, 6, 7, 8, 9, '...', 20]);
      expect(result.current.generate(8, 20)).toEqual([1, '...', 6, 7, 8, 9, 10, '...', 20]);
      expect(result.current.generate(9, 20)).toEqual([1, '...', 7, 8, 9, 10, 11, '...', 20]);
      expect(result.current.generate(10, 20)).toEqual([1, '...', 8, 9, 10, 11, 12, '...', 20]);
      expect(result.current.generate(11, 20)).toEqual([1, '...', 9, 10, 11, 12, 13, '...', 20]);
      expect(result.current.generate(12, 20)).toEqual([1, '...', 10, 11, 12, 13, 14, '...', 20]);
      expect(result.current.generate(13, 20)).toEqual([1, '...', 11, 12, 13, 14, 15, '...', 20]);
      expect(result.current.generate(14, 20)).toEqual([1, '...', 12, 13, 14, 15, 16, '...', 20]);
      expect(result.current.generate(15, 20)).toEqual([1, '...', 13, 14, 15, 16, 17, '...', 20]);
      expect(result.current.generate(16, 20)).toEqual([1, '...', 14, 15, 16, 17, 18, 19, 20]);
      expect(result.current.generate(17, 20)).toEqual([1, '...', 15, 16, 17, 18, 19, 20]);
      expect(result.current.generate(18, 20)).toEqual([1, '...', 16, 17, 18, 19, 20]);
      expect(result.current.generate(19, 20)).toEqual([1, '...', 17, 18, 19, 20]);
      expect(result.current.generate(20, 20)).toEqual([1, '...', 18, 19, 20]);
      // Test higher page and total
      expect(result.current.generate(999, 1200)).toEqual([
        1,
        '...',
        997,
        998,
        999,
        1000,
        1001,
        '...',
        1200,
      ]);
    });
  });
  it('should generate pagination with offset of 1', () => {
    const { result } = renderHook(() => usePagination({ offset: 1 }));
    act(() => {
      // Edge case only 1 total pages
      expect(result.current.generate(10, 1)).toEqual([1]);
      expect(result.current.generate(1, 20)).toEqual([1, 2, '...', 20]);
      expect(result.current.generate(2, 20)).toEqual([1, 2, 3, '...', 20]);
      expect(result.current.generate(3, 20)).toEqual([1, 2, 3, 4, '...', 20]);
      expect(result.current.generate(4, 20)).toEqual([1, '...', 3, 4, 5, '...', 20]);
      expect(result.current.generate(5, 20)).toEqual([1, '...', 4, 5, 6, '...', 20]);
      expect(result.current.generate(6, 20)).toEqual([1, '...', 5, 6, 7, '...', 20]);
      expect(result.current.generate(7, 20)).toEqual([1, '...', 6, 7, 8, '...', 20]);
      expect(result.current.generate(8, 20)).toEqual([1, '...', 7, 8, 9, '...', 20]);
      expect(result.current.generate(9, 20)).toEqual([1, '...', 8, 9, 10, '...', 20]);
      expect(result.current.generate(10, 20)).toEqual([1, '...', 9, 10, 11, '...', 20]);
      expect(result.current.generate(11, 20)).toEqual([1, '...', 10, 11, 12, '...', 20]);
      expect(result.current.generate(12, 20)).toEqual([1, '...', 11, 12, 13, '...', 20]);
      expect(result.current.generate(13, 20)).toEqual([1, '...', 12, 13, 14, '...', 20]);
      expect(result.current.generate(14, 20)).toEqual([1, '...', 13, 14, 15, '...', 20]);
      expect(result.current.generate(15, 20)).toEqual([1, '...', 14, 15, 16, '...', 20]);
      expect(result.current.generate(16, 20)).toEqual([1, '...', 15, 16, 17, '...', 20]);
      expect(result.current.generate(17, 20)).toEqual([1, '...', 16, 17, 18, '...', 20]);
      expect(result.current.generate(18, 20)).toEqual([1, '...', 17, 18, 19, 20]);
      expect(result.current.generate(19, 20)).toEqual([1, '...', 18, 19, 20]);
      expect(result.current.generate(20, 20)).toEqual([1, '...', 19, 20]);
      // Test higher page and total
      expect(result.current.generate(999, 1200)).toEqual([1, '...', 998, 999, 1000, '...', 1200]);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Here are the relevant scripts if you'd like to see the full approach: Pagination.tsx, usePagination.ts, Storybook consumer story.

A couple of things I'd note:

  • I don't recall ever seeing circular pagination (that's usually something I'd see like tabbing around a modal or tabs, but not pagination). Not sure if the UX is ideal.
  • Keyboard navigation is important. You should be able to tab through the paging controls. Perhaps even better would be to tab "into" controls, then use arrows (Zendesk Garden's pagination does this)
  • Probably the most challenging is large data sets and not having to loop huge data sets. The solution I went with uses currying approach which is completely independent of the size of the data set as it's only worried about the paging controls and where to place ellipses aka gap, and offsets.
Collapse
mbarzeev profile image
Matti Bar-Zeev Author

Thanks for the kind words @roblevintennis :)
Surely there are better ways to implement a Pagination React hook than what I did, and yours does looks like a more robust one. My goal in the article was to focus on the TDD aspect of developing such custom component, so it was never meant to be a complete Pagination solution :)
Having said that, I am not sure how I feel about the Hook supplying the array of links... In my case the hook is much "dumber" in the sense that I leave the buffering (and in your case, the offset ) to be something that the consuming component should take care of.
As for the cyclic nature of the pagination, you're right, this is more of a carousel kinda feature, but heck, why not? ;)
In any case, thanks for sharing your approach!

Collapse
roblevintennis profile image
Rob Levin

Gotcha, yeah, there are probably many viable approached — I'd say again that your tutorial on setting up the TDD test bed really really helped and was quite valuable! Not sure if you've ever read Kent Beck's TDD by example? But I have had a long love/hate with unit tests but one case where I feel they shine is when you feel a bit uncertain as to how to go about something and, for me, implementing pagination is quite a challenge indeed!

Good stuff…the dev world is definitely better when we share these ideas! Thanks :)

Thread Thread
mbarzeev profile image
Matti Bar-Zeev Author

Cannot agree more :)

Collapse
vfonic profile image
Viktor • Edited on

Great tutorial! Thank you!

Instead of using useReducer for such a simple use case, you could create a new function that would check the new cursor value before calling hook's setCursor:

const handleSetCursor = useCallback(newCursor => {
  const result = newCursor;
  if (newCursor < 0)...

  return result;
}, [totalPages, cursor])
Enter fullscreen mode Exit fullscreen mode

...alrhough this also looks complicated so I'm not sure what's better.

I'd also rename cursor to page as I'd reserve cursor for GraphQL pagination cursor which is usually not a number but a string.

Sorry for formatting, I'm typing this on my phone w/ autocorrect.

Collapse
vrunishkajo profile image
vrunishkajo

Wow, I replay this tutorial =) Thx.

Collapse
raiben23 profile image
Alex Cáceres

Great tutorial! Super understandable!!

Collapse
nikoheikkila profile image
Niko Heikkilä

Great stuff! Tutorials are so much easier to follow when you use a TDD cycle. Divide and conquer simply works.

Collapse
automateeverythingm profile image
Marko Pavic

We need more testing content. Tnx great post.

Collapse
mbarzeev profile image
Matti Bar-Zeev Author

Thansk! Is there any testing content you find lacking in particular?

Collapse
aytacg26 profile image
Aytac Güley

Great tutor and with TDD, it is excellent. Hope to see more tutors with TDD approach