الگوهای تست برای کامپوننت های ری اکت — راهنمای کاربردی
هر کدی نیاز به تست دارد و کامپوننتهای React نیز از این قاعده مستثنا نیستند. در این مقاله با روش طراحی کامپوننتهای React آشنا میشویم که قابلیت تستپذیری آنها را ارتقا میدهد. با ما تا انتهای این راهنما همراه باشید تا الگوهای تست برای کامپوننت های ری اکت را بشناسید.
کامپوننت زیر به نام HelloName وقتی مقدار name در props برابر با James باشد، عبارت Hello James را رندر میکند. از آنجا که این مقدار در مالکیت HelloName نیست، با استفاده از یک دکوراتور «کامپوننت مرتبه بالا» (High Order Component) به نام WithName به آن ارسال میشود. این رویه در مواردی که کتابخانهها از API مربوط به context ریاکت استفاده نمیکند معمولاً رایج است.
1import React from 'react';
2import WithName from './WithName';
3
4function HelloName({name}) {
5 return (
6 <div>
7 Hello {name}
8 </div>
9 );
10}
11
12export default WithName(HelloName);
دکوراتور name نیز به صورت زیر است:
1import React from 'react';
2
3export default function WithName(Component){
4 class AddName extends React.Component {
5 render(){
6 return <Component name='James' />
7 }
8 }
9
10 return AddName
11}
رابطه فوق بین HelloName و WithName بسیار رایج است، زیرا از آن برای دستیابی به چیزهای زیادی در React بهره میگیریم. اگر از «ریداکس» (Redux) استفاده کرده باشید، این رویه برای شما کاملاً آشنا است، زیرا در آنجا از این نوع رابطه برای اتصال کامپوننتها به حالت سراسری استفاده میشود.
برای تست HelloName به روش فوق، تست باید چیزی مانند زیر باشد تا صرفاً آن را رندر کند:
1import React from 'react';
2import { render } from '@testing-library/react';
3import HelloName from './HelloName';
4
5test('Says Hello James', () => {
6 const { getByText } = render(<HelloName />);
7 const linkElement = getByText(/Hello James/i);
8 expect(linkElement).toBeInTheDocument();
9});
اینک مشکل این است که هدف از تست این است که کامپوننت را در معرض حالتها و props ممکن تصادفی قرار دهد و کارکرد آن را بررسی کند. فرض کنید مالک کامپوننت WithName نیستید که پدیده رایجی است. این بدان معنی است که دسترسی آسانی به متغیر name برای دستکاری آن و تست سناریوهای مختلف ندارید. این وضعیت در مواردی که مقدار مورد نظر در HelloName برای انجام کاری استفاده شود که کامپوننت به جای رندر شدن صرف، نتیجهای نیز تولید کند، باز هم پیچیدهتر میشود. برای نمونه باید تست کنیم که آیا Hello James و در مورد دیگر Hello Mike رندر میشود یا نه.
راه حل این مشکل کاملاً ساده است. نخستین کاری که باید انجام دهیم این است که کامپوننت HelloName را قبل از اعمال دکوراتور اکسپورت کنیم و نسخه با اعمال دکوراتور را به صورت پیشفرض درآوریم.
1import React from 'react';
2import WithName from './WithName';
3
4export function HelloName({name}) {
5 return (
6 <div>
7 Hello {name}
8 </div>
9 );
10}
11
12export default WithName(HelloName);
در این تست نسخه با اعمال دکوراتور تست میشود و سپس نسخه بدون دکوراتور را میتوان به هر تعداد نیاز تست کرد.
1import React from 'react';
2import { render } from '@testing-library/react';
3import {HelloName} from './HelloName';
4import HelloNameDecorated from './HelloName';
5
6test('Says Hello James', () => {
7 const { getByText } = render(<HelloNameDecorated />);
8 const element = getByText(/Hello James/i);
9 expect(element).toBeInTheDocument();
10});
11test('Says Hello Mike', () => {
12 const { getByText } = render(<HelloName name='Mike' />);
13 const element = getByText(/Hello Mike/i);
14 expect(element).toBeInTheDocument();
15});
16test('Says Hello Danstan', () => {
17 const { getByText } = render(<HelloName name='Danstan' />);
18 const element = getByText(/Hello Danstan/i);
19 expect(element).toBeInTheDocument();
20});
21test('Says Hello Mary', () => {
22 const { getByText } = render(<HelloName name='Mary' />);
23 const element = getByText(/Hello Mary/i);
24 expect(element).toBeInTheDocument();
25});
این الگو در مواردی که قرار باشد یک اپلیکیشن را تست کنیم و در آن اپلیکیشن استفاده گستردهای از Redux و Redux Form شده باشد، سهولت زیادی ایجاد میکند.
تست کردن اکشنهای کامپوننت
الگوی دیگری که به منظور سهولت تست میتوان استفاده کرد، در زمان تست کردن اکشنهایی است که درون یک کامپوننت رخ میدهند.
کامپوننت Form را در نظر بگیرید که یک فرم HTML را با یک ورودی برای مقدار name رندر میکند. این متغیر name به متغیر حالت name اتصال یافته است و این فرم یک دکمه دارد که رویداد onClick آن به یک تابع اتصال یافته و آن را اجرا میکند.
1import React, { useState } from 'react';
2
3export default function Form({ onSubmit }) {
4
5 const [name, setName] = useState('')
6
7 const handleSubmit = (e) => {
8 // Do something with value of name
9 }
10 return (
11 <form>
12 <input onChange={(e) => setName(e.target.value)} name='name' value={name} />
13 <button onClick={handleSubmit}>Submit</button>
14 </form>
15 )
16}
اکنون باید این کد را در رندر تست کنیم. زمانی که متن در ورودی تایپ میشود و سپس روی دکمه کلیک میکنیم، تابع handleSubmit یک بار فراخوانی میشود.
مشکل این جا است که handleSubmit درون کامپوننت فرم تعریف شده است و از این رو دسترسی به آن ممکن نیست. برای این که آن را تستپذیر بکنیم، باید تابع handleSubmit را از کامپوننت به props انتقال بدهیم.
1import React, { useState } from 'react';
2
3export default function Form({ handleSubmit }) {
4
5 const [name, setName] = useState('')
6 return (
7 <form onSubmit={() => handleSubmit({ name })}>
8 <input onChange={(e) => setName(e.target.value)} name='name' value={name} />
9 <button type='submit'>Submit</button>
10 </form>
11 )
12}
اینک برای تست کامپوننت باید handleSubmit را که اینک در props است با یک مشابه ساختگی جایگزین کنیم:
1import React from 'react';
2import Form from './Form';
3
4import Enzyme from 'enzyme';
5import Adapter from 'enzyme-adapter-react-16';
6
7Enzyme.configure({ adapter: new Adapter() });
8
9describe('Test Form Submit', () => {
10 it('Test form Submit', () => {
11 const mockCallBack = jest.fn();
12
13 const form = Enzyme.shallow((<Form handleSubmit={mockCallBack}/>));
14 form.find('input').simulate('change', {target: {value: 'James'}});
15 form.simulate('submit');
16 expect(mockCallBack.mock.calls.length).toEqual(1);
17 expect(mockCallBack.mock.calls).toEqual([[{name: 'James'}]]);
18 });
19});
این روش برای انتقال اکشنهای بیرونی از کامپوننت به props موجب میشود که بتوانیم کامپوننت را برای تست بهتر اکشنهایی که کاربر در زمان تعامل با کامپوننت ایجاد میکند دستکاری کنیم.
کامپوننتهای دارای نسخههای پیچیده از حالت
فرض کنید کامپوننت زیر را دارید. کامپوننت Profile به متغیر user_id در localStorage وابسته است و از این رو حالتهای زیر را میگیرد:
- زمانی که هیچ user_id در localStorage نباشد، صفحه لاگین را نمایش میدهد.
- تنها زمانی نمایش مییابد که کاربر لاگین کرده باشید.
- در صورتی که user_id در localStorage برابر با user_id در Profile نباشد، \دکمه follow را نشان میدهد.
- اگر user_id در localStorage برابر با user_id در Profile باشد، دکمه edit را نمایش میدهد.
1import React from 'react';
2
3export default function Profile() {
4
5 const userId = localStorage.getItem('user_id');
6
7 const user = { name: 'James Allen' } // From some API
8
9 return (
10 <div>
11
12 {!userId && <div>Please Login First</div>}
13 {
14 userId &&
15 <div>
16 <h1>Profile</h1>
17
18 <p>Name: {user.name}</p>
19
20 {user.id === userId && <button>Edit</button>}
21
22 {user.id !== userId && <button>Follow</button>}
23
24 </div>
25 }
26
27 </div>
28 )
29}
توجه کنید که این صرفاً یک مثال است. شما نباید از localStorage برای نمایش لاگین کردن یا نکردن کاربر استفاده کنید. این بهترین روش برای انجام این کار نیست، گرچه کار میکند و کاملاً ساده است.
اکنون برای تست کامپوننت فوق، باید مطمئن شویم که دادههای مورد استفاده برای رندر کامپوننت کاملاً از آن مجزا هستند به طوری که دستکاری کامپوننت آسان میشود.
نخستین کاری که باید بکنیم این است که کامپوننت مالک دادهها باشد، یعنی کامپوننت دارای کمترین عوارض جانبی باشد و تنها دادهها را عرضه کند. در صورت امکان باید به صورت کامل از رندر نیز اجتناب کند. در ریداکس این وضعیت از طریق یک Reducer و یک Provider با نوعی HOC یا هر نامی که MobX یا Flux به آن میدهند ممکن است.
در این راه حل نمونه از React Context برای ارائه دادهها به کامپوننت استفاده کردهایم. به این ترتیب یک ProfileDataContext/Provider داریم:
1import React from 'react';
2
3export const ProfileDataContext = React.createContext({});
4
5export const ProfileDataProvider = ProfileDataContext.Provider
6
7export const ProfileDataDecorator = (ChildComponent) => {
8 return function (){
9 const data = {user: {name: 'Mike Allen', id: 2}, userId: 2} // user Received from some API, while user_id from localStorage
10 return (
11 <ProfileDataContext.Provider value={data}>
12 <ChildComponent/>
13 </ProfileDataContext.Provider>
14 );
15 }
16}
اینک کامپوننت ما به صورت زیر در آمده است:
1import React, {useContext} from 'react';
2import {ProfileDataContext, ProfileDataDecorator} from './ProfileDataProvider'
3
4export function Profile(props) {
5
6 const {user, userId} = useContext(ProfileDataContext)
7
8 return (
9 <div>
10
11 {!userId && <div>Please Login First</div>}
12 {
13 userId &&
14 <div>
15 <h1>Profile</h1>
16
17 <p>Name: {user.name}</p>
18
19 {user.id === userId && <button>Edit</button>}
20
21 {user.id !== userId && <button>Follow</button>}
22
23 </div>
24 }
25
26 </div>
27 )
28}
29
30export default ProfileDataDecorator(Profile)
اکنون در تستها میتوانیم از کامپوننت خام استفاده کنیم و Provider خود را با دادههایمان در اختیار آن قرار دهیم. سپس میتوانیم هر تعداد سناریو را که میخواهیم تست کنیم و مهم نیست که دادههای پروفایل چه قدر پیچیده هستند و همچنین پیچیدگی رندر و حتی فرزندان در کامپوننت Profile اهمیتی ندارد. در ادامه چهار حالت مختلف را تست کردهایم که نماینده نسخههای مختلفی از کامپوننتها هستند:
1import React from 'react';
2import {Profile} from './Profile';
3import { render, screen } from '@testing-library/react';
4import { ProfileDataProvider } from './ProfileDataProvider';
5
6
7describe('Profile Component', () => {
8 test('Should show login only', () => {
9 const withNoUser = {}
10 const { getByText } = render(<ProfileDataProvider value={withNoUser}><Profile/></ProfileDataProvider>);
11 const loginElement = getByText(/Please login first/i);
12 expect(loginElement).toBeInTheDocument();
13 });
14
15 test('Should show Edit, no Follow', () => {
16 const withUserSignedIn = {user: {name: 'Danstan Onyango', id: 2}, userId: 2}
17 const { getByText } = render(<ProfileDataProvider value={withUserSignedIn}><Profile/></ProfileDataProvider>);
18 const nameElement = getByText(/Name: Danstan Onyango/i);
19 const EditElement = getByText(/Edit/i);
20 expect(nameElement).toBeInTheDocument();
21 expect(EditElement).toBeInTheDocument();
22 expect(screen.queryByText(/Follow/i)).toBeNull()
23 });
24 test('Should show Follow, no Edit', () => {
25 const withUserDifferentUserSignedIn = {user: {name: 'Danstan Onyango', id: 2}, userId: 3}
26 const { getByText } = render(<ProfileDataProvider value={withUserDifferentUserSignedIn}><Profile/></ProfileDataProvider>);
27 const nameElement = getByText(/Name: Danstan Onyango/i);
28 const FollowElement = getByText(/Follow/i);
29 expect(nameElement).toBeInTheDocument();
30 expect(FollowElement).toBeInTheDocument();
31 expect(screen.queryByText(/Edit/i)).toBeNull()
32 });
33});
سخن پایانی
در این مقاله با روشهای طراحی الگوهای تستپذیر برای کامپوننتها آشنا شدیم. این روشها موجب میشوند که تست کردن کامپوننتها بسیار آسان شود. نکات کلیدی به شرح زیر هستند:
- تا حدی که میتوانید کامپوننتهای خالص (Pure) طراحی کنید.
- هر چه کامپوننتهای عوارض جانبی کمتری داشته باشند، آنها را آسانتر میتوان شبیهسازی کرد.
- اکشنهای کامپوننتها را به صورت والدین خالص جدا کنید تا بتوانید به سهولت آنها را شبیهسازی کنید.
- در حد امکان دادهها را جداسازی کنید.
امیدواریم این راهنما برای شما مفید بوده باشد. کد کامل موارد مطرح شده در این راهنما را در این ریپوی گیتهاب (+) ببینید.
اگر این مطلب برای شما مفید بوده است، آموزشهای زیر نیز به شما پیشنهاد میشوند:
- مجموعه آموزشهای JavaScript (جاوا اسکریپت)
- مجموعه آموزشهای برنامهنویسی
- آموزش JavaScript ES6 (جاوا اسکریپت)
- هشت ترفند مفید برای توسعه اپلیکیشن های React — راهنمای کاربردی
- ارسال پارامترهای چندگانه مسیر در React — به زبان ساده
==