Implementing MVVM with React to improve your tests

MVVM (Model-View-ViewModel) architecture is a powerful paradigm for separating concerns and enhancing testability in your front-end applications. One of its key benefits lies in its ability to isolate the view layer, allowing for independent testing and abstraction of UI-specific concerns.

With MVVM, testing your views becomes a breeze as you can focus solely on the presentation layer without worrying about underlying logic. This separation enables you to abstract away HTML structures, page layouts, and other UI-only considerations, streamlining your testing process.

However, implementing MVVM in React comes with its own set of challenges. React’s embrace of hooks revolutionized front-end development, but it introduced complexities, especially when it comes to testing. Asynchronous hooks pose a particular challenge, requiring specific techniques like the waitFor helper to ensure proper testing of initial states.

Let’s take a deeper look:

View: The view remains free from business logic, focusing solely on rendering components and handling user interactions:

export default function Playlist({playlistId, player, fetchPlaylist}: {
    playlistId: number,
    player: Player,
    fetchPlaylist: (id: number) => Promise<Playlist | undefined>
}) {
    const {
        playAll,
        play,
        removeTrack,
        playlist
    } = usePlaylist(playlistId, player, fetchPlaylist);

    return (
        <main>
            <p>Playlist: {playlist?.title}</p>
            <button onClick={() => playAll()}>Play All</button>
            <table>
                <thead>
                <tr>
                    <th>Title</th>
                    <th>Artist</th>
                    <th>Album</th>
                    <th>Date added</th>
                    <th>Time</th>
                    <th></th>
                </tr>
                </thead>
                <tbody>
                {playlist?.tracks.map(t => (
                    <tr key={t.title}>
                        <td>{t.title}</td>
                        <td>{t.artist}</td>
                        <td>{t.album}</td>
                        <td>{t.dateAdded}</td>
                        <td>{t.duration}</td>
                        <td>
                            <button onClick={() => play(t)}>Play</button>
                            <button onClick={() => removeTrack(t)}>Remove</button>
                        </td>
                    </tr>
                ))}
                </tbody>
            </table>
        </main>)
}

ViewModel: This is where the magic happens! The ViewModel encapsulates business logic and behavior for the view, ensuring clean and testable code. Testing the ViewModel becomes straightforward, allowing for comprehensive coverage of application logic. In React we can call it a companion hook. The companion hook serves the View.

export const usePlaylist = (id: number, player: Player, fetchPlaylist: (id: number) => Promise<Playlist | undefined>) => {
    const [playlist, setPlaylist] = useState<Playlist>();

    useEffect(() => {
        fetchPlaylist(+id)
            .then(data => {
                if (data) {
                    setPlaylist(data);
                }
            });
    }, [id]);

    const removeTrack = (track: Track) => {
        setPlaylist(prevPlaylist => {
            if (prevPlaylist) {
                return remove(prevPlaylist, track);
            }
            return prevPlaylist;
        });
    };

    const moveTrack = (track: Track, newPosition: number) => {
        setPlaylist(prevPlaylist => {
            if (prevPlaylist) {
                return move(prevPlaylist, track, newPosition);
            }
            return prevPlaylist;
        });
    };

    const editPlaylist = (newTitle: string, newDescription: string) => {
        setPlaylist(prevPlaylist => {
            if (prevPlaylist) {
                return edit(prevPlaylist, newTitle, newDescription);
            }
            return prevPlaylist;
        });
    };

    const play = (track: Track) => {
        player.play(new PlayerTrack(track.title));
    };

    const playAll = () => {
        if (playlist) {
            const playerTracks: PlayerTrack[] = playlist.tracks.map(t => ({ title: t.title }));
            player.playAll(playerTracks);
        }
    };

    return {
        playlist,
        removeTrack,
        moveTrack,
        editPlaylist,
        play,
        playAll
    };
};

Tests: Using this technique below enables us to implement TDD for our component behaviors without having to worry with the page structures, and other things that are exclusive to the view.

Testing MVVM components in React, especially asynchronous hooks, can be daunting. However, with the right tools and techniques, it’s entirely achievable. Leveraging the waitFor helper, we can effectively test asynchronous hooks, ensuring that our application behaves as expected under various conditions:

describe('PlaylistViewModel', () => {
    const player = new Player();

    const playlist = playlists[0];
    const fetchPlaylist = (id: number) => Promise.resolve(playlist);

    it('should load correct data', async () => {
        const { result } = renderHook(() => usePlaylist(1, player, fetchPlaylist));
        await waitFor(() => result.current.playlist !== undefined);
        expect(result.current.playlist).toEqual(playlist);
    });

    it('should remove track from playlist', async () => {
        const { result } = renderHook(() => usePlaylist(1, player, fetchPlaylist));
        await waitFor(() => result.current.playlist !== undefined);

        act(() => {
            result.current.removeTrack(track1);
        });

        expect(result.current.playlist?.tracks).toEqual([track2]);
    });

    it('should play selected track', async () => {
        const { result } = renderHook(() => usePlaylist(1, player, fetchPlaylist));
        await waitFor(() => result.current.playlist !== undefined);

        act(() => {
            result.current.play(track1);
        });

        expect(player.currentTrack?.title).toBe(track1.title);
    });

    it('should move track to new position', async () => {
        const { result } = renderHook(() => usePlaylist(1, player, fetchPlaylist));
        await waitFor(() => result.current.playlist !== undefined);

        act(() => {
            result.current.moveTrack(track1, 2);
        });

        expect(result.current.playlist?.tracks).toEqual([track2, track1]);
    });

    it('should edit playlist title and description', async () => {
        const { result } = renderHook(() => usePlaylist(1, player, fetchPlaylist));
        await waitFor(() => result.current.playlist !== undefined);

        act(() => {
            result.current.editPlaylist('new title', 'new description');
        });

        expect(result.current.playlist?.title).toBe('new title');
        expect(result.current.playlist?.description).toBe('new description');
    });
});

Conclusion: with those hook tests in place, now you can focus on testing the view (html and events) from the user’s perspective. This separation will make your views simpler as they will only contain code that belongs there, and all the “business” related behaviors are encapsulated in the companion hook.

Full code sample is on my github page.


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *