الگوهای تست برای کامپوننت های ری اکت — راهنمای کاربردی

۱۷ بازدید
آخرین به‌روزرسانی: ۱۹ اسفند ۱۳۹۸
زمان مطالعه: ۶ دقیقه
الگوهای تست برای کامپوننت های ری اکت

هر کدی نیاز به تست دارد و کامپوننت‌های React نیز از این قاعده مستثنا نیستند. در این مقاله با روش طراحی کامپوننت‌های React آشنا می‌شویم که قابلیت تست‌پذیری آن‌ها را ارتقا می‌دهد. با ما تا انتهای این راهنما همراه باشید تا الگوهای تست برای کامپوننت های ری اکت را بشناسید.

کامپوننت زیر به نام HelloName وقتی مقدار name در props برابر با James باشد، عبارت Hello James را رندر می‌کند. از آنجا که این مقدار در مالکیت HelloName نیست، با استفاده از یک دکوراتور «کامپوننت مرتبه بالا» (High Order Component) به نام WithName به آن ارسال می‌شود. این رویه در مواردی که کتابخانه‌ها از API مربوط به context ری‌اکت استفاده نمی‌کند معمولاً رایج است.

import React from 'react';
import WithName from './WithName';

function HelloName({name}) {
	return (
		<div>
			Hello {name}
		</div>
	);
}

export default WithName(HelloName);

دکوراتور name نیز به صورت زیر است:

import React from 'react';

export default function WithName(Component){
	class AddName extends React.Component {
		render(){
			return <Component name='James' />
		}
	}
 
    return AddName
}

رابطه فوق بین HelloName و WithName بسیار رایج است، زیرا از آن برای دستیابی به چیزهای زیادی در React بهره می‌گیریم. اگر از «ریداکس» (Redux) استفاده کرده باشید، این رویه برای شما کاملاً آشنا است، زیرا در آنجا از این نوع رابطه برای اتصال کامپوننت‌ها به حالت سراسری استفاده می‌شود.

برای تست HelloName به روش فوق، تست باید چیزی مانند زیر باشد تا صرفاً آن را رندر کند:

import React from 'react';
import { render } from '@testing-library/react';
import HelloName from './HelloName';

test('Says Hello James', () => {
	const { getByText } = render(<HelloName />);
	const linkElement = getByText(/Hello James/i);
	expect(linkElement).toBeInTheDocument();
});

اینک مشکل این است که هدف از تست این است که کامپوننت را در معرض حالت‌ها و props ممکن تصادفی قرار دهد و کارکرد آن را بررسی کند. فرض کنید مالک کامپوننت WithName نیستید که پدیده رایجی است. این بدان معنی است که دسترسی آسانی به متغیر name برای دستکاری آن و تست سناریوهای مختلف ندارید. این وضعیت در مواردی که مقدار مورد نظر در HelloName برای انجام کاری استفاده شود که کامپوننت به جای رندر شدن صرف، نتیجه‌ای نیز تولید کند، باز هم پیچیده‌تر می‌شود. برای نمونه باید تست کنیم که آیا Hello James و در مورد دیگر Hello Mike رندر می‌شود یا نه.

راه حل این مشکل کاملاً ساده است. نخستین کاری که باید انجام دهیم این است که کامپوننت HelloName را قبل از اعمال دکوراتور اکسپورت کنیم و نسخه با اعمال دکوراتور را به صورت پیش‌فرض درآوریم.

import React from 'react';
import WithName from './WithName';

export function HelloName({name}) {
	return (
		<div>
			Hello {name}
		</div>
	);
}

export default WithName(HelloName);

در این تست نسخه با اعمال دکوراتور تست می‌شود و سپس نسخه بدون دکوراتور را می‌توان به هر تعداد نیاز تست کرد.

import React from 'react';
import { render } from '@testing-library/react';
import {HelloName} from './HelloName';
import HelloNameDecorated from './HelloName';

test('Says Hello James', () => {
	const { getByText } = render(<HelloNameDecorated />);
	const element = getByText(/Hello James/i);
	expect(element).toBeInTheDocument();
});
test('Says Hello Mike', () => {
	const { getByText } = render(<HelloName name='Mike' />);
	const element = getByText(/Hello Mike/i);
	expect(element).toBeInTheDocument();
});
test('Says Hello Danstan', () => {
	const { getByText } = render(<HelloName name='Danstan' />);
	const element = getByText(/Hello Danstan/i);
	expect(element).toBeInTheDocument();
});
test('Says Hello Mary', () => {
	const { getByText } = render(<HelloName name='Mary' />);
	const element = getByText(/Hello Mary/i);
	expect(element).toBeInTheDocument();
});

این الگو در مواردی که قرار باشد یک اپلیکیشن را تست کنیم و در آن اپلیکیشن استفاده گسترده‌ای از Redux و Redux Form شده باشد، سهولت زیادی ایجاد می‌کند.

تست کردن اکشن‌های کامپوننت

الگوی دیگری که به منظور سهولت تست می‌توان استفاده کرد، در زمان تست کردن اکشن‌هایی است که درون یک کامپوننت رخ می‌دهند. کامپوننت Form را در نظر بگیرید که یک فرم HTML را با یک ورودی برای مقدار name رندر می‌کند. این متغیر name به متغیر حالت name اتصال یافته است و این فرم یک دکمه دارد که رویداد onClick آن به یک تابع اتصال یافته و آن را اجرا می‌کند.

import React, { useState } from 'react';

export default function Form({ onSubmit }) {

	const [name, setName] = useState('')

	const handleSubmit = (e) => {
		// Do something with value of name
	}
	return (
		<form>
			<input onChange={(e) => setName(e.target.value)} name='name' value={name} />
			<button onClick={handleSubmit}>Submit</button>
		</form>
	)
}

اکنون باید این کد را در رندر تست کنیم. زمانی که متن در ورودی تایپ می‌شود و سپس روی دکمه کلیک می‌کنیم، تابع handleSubmit یک بار فراخوانی می‌شود.

مشکل این جا است که handleSubmit درون کامپوننت فرم تعریف شده است و از این رو دسترسی به آن ممکن نیست. برای این که آن را تست‌پذیر بکنیم، باید تابع handleSubmit را از کامپوننت به props انتقال بدهیم.

import React, { useState } from 'react';

export default function Form({ handleSubmit }) {

	const [name, setName] = useState('')
	return (
		<form onSubmit={() => handleSubmit({ name })}>
			<input onChange={(e) => setName(e.target.value)} name='name' value={name} />
			<button type='submit'>Submit</button>
		</form>
	)
}

اینک برای تست کامپوننت باید handleSubmit را که اینک در props است با یک مشابه ساختگی جایگزین کنیم:

import React from 'react';
import Form from './Form';

import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({ adapter: new Adapter() });

describe('Test Form Submit', () => {
	it('Test form Submit', () => {
		const mockCallBack = jest.fn();

		const form = Enzyme.shallow((<Form handleSubmit={mockCallBack}/>));
		form.find('input').simulate('change', {target: {value: 'James'}});
		form.simulate('submit');
		expect(mockCallBack.mock.calls.length).toEqual(1);
		expect(mockCallBack.mock.calls).toEqual([[{name: 'James'}]]);
	});
});

این روش برای انتقال اکشن‌های بیرونی از کامپوننت به props موجب می‌شود که بتوانیم کامپوننت را برای تست بهتر اکشن‌هایی که کاربر در زمان تعامل با کامپوننت ایجاد می‌کند دستکاری کنیم.

کامپوننت‌های دارای نسخه‌های پیچیده از حالت

فرض کنید کامپوننت زیر را دارید. کامپوننت Profile به متغیر user_id در localStorage وابسته است و از این رو حالت‌های زیر را می‌گیرد:

  • زمانی که هیچ user_id در localStorage نباشد، صفحه لاگین را نمایش می‌دهد.
  • تنها زمانی نمایش می‌یابد که کاربر لاگین کرده باشید.
  • در صورتی که user_id در localStorage برابر با user_id در Profile نباشد، \دکمه follow را نشان می‌دهد.
  • اگر user_id در localStorage برابر با user_id در Profile باشد، دکمه edit را نمایش می‌دهد.
import React from 'react';

export default function Profile() {

		const userId = localStorage.getItem('user_id');

		const user = { name: 'James Allen' } // From some API

		return (
				<div>

					{!userId && <div>Please Login First</div>}
					{
						userId &&
						<div>
							<h1>Profile</h1>

							<p>Name: {user.name}</p>

							{user.id === userId && <button>Edit</button>}

							{user.id !== userId && <button>Follow</button>}

						</div>
					}

				</div>
		)
}

توجه کنید که این صرفاً یک مثال است. شما نباید از localStorage برای نمایش لاگین کردن یا نکردن کاربر استفاده کنید. این بهترین روش برای انجام این کار نیست، گرچه کار می‌کند و کاملاً ساده است.

اکنون برای تست کامپوننت فوق، باید مطمئن شویم که داده‌های مورد استفاده برای رندر کامپوننت کاملاً از آن مجزا هستند به طوری که دستکاری کامپوننت آسان می‌شود.

نخستین کاری که باید بکنیم این است که کامپوننت مالک داده‌ها باشد، یعنی کامپوننت دارای کمترین عوارض جانبی باشد و تنها داده‌ها را عرضه کند. در صورت امکان باید به صورت کامل از رندر نیز اجتناب کند. در ریداکس این وضعیت از طریق یک Reducer و یک Provider با نوعی HOC یا هر نامی که MobX یا Flux به آن می‌دهند ممکن است.

در این راه حل نمونه از React Context برای ارائه داده‌ها به کامپوننت استفاده کرده‌ایم. به این ترتیب یک ProfileDataContext/Provider داریم:

import React from 'react';

export const ProfileDataContext = React.createContext({});

export const ProfileDataProvider = ProfileDataContext.Provider

export const ProfileDataDecorator = (ChildComponent) => {
	return function (){
		const data = {user: {name: 'Mike Allen', id: 2}, userId: 2} // user Received from some API, while user_id from localStorage
		return (
			<ProfileDataContext.Provider value={data}>
				<ChildComponent/>
			</ProfileDataContext.Provider>
		);
	}
}

اینک کامپوننت ما به صورت زیر در آمده است:

import React, {useContext} from 'react';
import {ProfileDataContext, ProfileDataDecorator} from './ProfileDataProvider'

export function Profile(props) {

	const {user, userId} = useContext(ProfileDataContext)

	return (
		<div>

		{!userId && <div>Please Login First</div>}
		{
		   userId &&
		   <div>
			<h1>Profile</h1>

			<p>Name: {user.name}</p>

			{user.id === userId && <button>Edit</button>}

			{user.id !== userId && <button>Follow</button>}

		   </div>
	       }

	</div>
     )
}

export default ProfileDataDecorator(Profile)

اکنون در تست‌ها می‌توانیم از کامپوننت خام استفاده کنیم و Provider خود را با داده‌هایمان در اختیار آن قرار دهیم. سپس می‌توانیم هر تعداد سناریو را که می‌خواهیم تست کنیم و مهم نیست که داده‌های پروفایل چه قدر پیچیده هستند و همچنین پیچیدگی رندر و حتی فرزندان در کامپوننت Profile اهمیتی ندارد. در ادامه چهار حالت مختلف را تست کرده‌ایم که نماینده نسخه‌های مختلفی از کامپوننت‌ها هستند:

import React from 'react';
import {Profile} from './Profile';
import { render, screen } from '@testing-library/react';
import { ProfileDataProvider } from './ProfileDataProvider';


describe('Profile Component', () => {
	test('Should show login only', () => {
		const withNoUser = {}
		const { getByText } = render(<ProfileDataProvider value={withNoUser}><Profile/></ProfileDataProvider>);
		const loginElement = getByText(/Please login first/i);
		expect(loginElement).toBeInTheDocument();
	});

	test('Should show Edit, no Follow', () => {
		const withUserSignedIn = {user: {name: 'Danstan Onyango', id: 2}, userId: 2}
		const { getByText } = render(<ProfileDataProvider value={withUserSignedIn}><Profile/></ProfileDataProvider>);
		const nameElement = getByText(/Name: Danstan Onyango/i);
		const EditElement = getByText(/Edit/i);
		expect(nameElement).toBeInTheDocument();
		expect(EditElement).toBeInTheDocument();
		expect(screen.queryByText(/Follow/i)).toBeNull()
	});
	test('Should show Follow, no Edit', () => {
		const withUserDifferentUserSignedIn = {user: {name: 'Danstan Onyango', id: 2}, userId: 3}
		const { getByText } = render(<ProfileDataProvider value={withUserDifferentUserSignedIn}><Profile/></ProfileDataProvider>);
		const nameElement = getByText(/Name: Danstan Onyango/i);
		const FollowElement = getByText(/Follow/i);
		expect(nameElement).toBeInTheDocument();
		expect(FollowElement).toBeInTheDocument();
		expect(screen.queryByText(/Edit/i)).toBeNull()
	});
});

سخن پایانی

در این مقاله با روش‌های طراحی الگوهای تست‌پذیر برای کامپوننت‌ها آشنا شدیم. این روش‌ها موجب می‌شوند که تست کردن کامپوننت‌ها بسیار آسان شود. نکات کلیدی به شرح زیر هستند:

  • تا حدی که می‌توانید کامپوننت‌های خالص (Pure) طراحی کنید.
  • هر چه کامپوننت‌های عوارض جانبی کمتری داشته باشند، آن‌ها را آسان‌تر می‌توان شبیه‌سازی کرد.
  • اکشن‌های کامپوننت‌ها را به صورت والدین خالص جدا کنید تا بتوانید به سهولت آن‌ها را شبیه‌سازی کنید.
  • در حد امکان داده‌ها را جداسازی کنید.

امیدواریم این راهنما برای شما مفید بوده باشد. کد کامل موارد مطرح شده در این راهنما را در این ریپوی گیت‌هاب (+) ببینید.

اگر این مطلب برای شما مفید بوده است، آموزش‌های زیر نیز به شما پیشنهاد می‌شوند:

==

بر اساس رای ۰ نفر
آیا این مطلب برای شما مفید بود؟
شما قبلا رای داده‌اید!
اگر بازخوردی درباره این مطلب دارید یا پرسشی دارید که بدون پاسخ مانده است، آن را از طریق بخش نظرات مطرح کنید.
منابع:
itnext

نظر شما چیست؟

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *