تست واحد یکی از بهترین روشها برای نوشتن کد موثر است. در این مقاله میخواهیم به شما نشان دهیم که این نوع تست دقیقا چیست و با برخی اصطلاحات مربوط به آن شما را آشنا کنیم. از آنجا که من بیشتر با اکوسیستمهای TypeScript و React کار میکنم، به ابزارها و مثالهایی اشاره میکنم که معمولا در این دو استفاده میشوند، اما اصطلاحات و تعاریف موجود در این مقاله برای همه زبانها و فناوریها نیز قابل استفاده است.
انواع تست
قبل از اینکه به موضوع یونیت تست (تست واحد) بپردازیم، باید انواع دیگر تست را نیز بشناسیم. به طور کلی سه نوع تست نرمافزار وجود دارد:
- End-to-End
- Integration
- Unit
تستهای واحد
تستهای واحد (که تستهای ماژول نیز نامیده میشوند) برای تست ماژولار بودن انجام میشوند. آنها قسمت خاصی از سیستم (ماژول) را به طور مستقل از سایر ماژولهای سیستم تست میکنند.
این نوع تست، خروجی ماژول (نتیجهای که تابع برمیگرداند) را با پارامترهای مختلف ورودی بررسی میکند. دقت کنید این تست نتیجه ماژولهای دیگر را چک نمیکند بلکه تنها خروجی ماژولی را که برای آن نوشته شده است بررسی میکند.
تست واحد میتواند نوعی مستند سازی ماژولها درنظر گرفته شود.
Unit (واحد) چیست؟
اکنون میدانیم که از تستهای واحد برای تست ماژول استفاده میشود. اما واحد چیست؟ این به فناوریها و زبانهای برنامه نویسی که استفاده میکنید بستگی دارد. در TypeScript یا JavaScript میتواند یک تابع یا کلاس باشد. در React یک کامپوننت است که اساسا نوعی تابع جاوااسکریپت است.
برای هر واحد باید یک فایل مستقل بنویسیم که حاوی تستهای واحد (ماژول) باشد.
اما اگر یک کلاس یا کامپوننت شامل چندین متد یا تابع باشد، چه میشود؟ آیا برای هر متد یا تابع لازم است یک تست مستقل بنویسیم؟
برای متدهای یک کلاس نوشتن تست منطقی نیست، زیرا متدها قسمتهای داخلی یک کلاس هستند که در آن قرار میگیرند. بیشتر اوقات این متدها در خارج از کلاسهایشان معنی ندارند، بنابراین متد کلاس درنظر گرفته نمیشوند بلکه یک تابع مستقل هستند (اگر در یک زبان برنامه نویسی امکان پذیر باشد).
در مورد کامپوننتهای React چطور؟ خب بستگی دارد. به عنوان مثال اگر state محلی در کامپوننت دارید، نوشتن تست برای تابع کامپوننت به عنوان واحد منطقی نیست، زیرا این تابع به احتمال زیاد با state کار میکند. در این حالت شما باید به کامپوننت به عنوان یک واحد فکر کنید و مهم نیست که دارای توابع داخلی باشد یا نه.
قبل از پاسخ به این سوال که چرا ترجیح میدهیم تستهای واحد را به عنوان یک توسعه دهنده بنویسیم؟ ابتدا باید در مورد انواع دیگر تستها اطلاعاتی کسب کنیم.
یک نمونه ساده Unit در TypeScript - تابع کمکی عوارض جانبی ندارد:
interface Transaction {
// ...
user: User;
}
export const getUsersFromTransactions = (transactions: Transaction[]) =>
transactions.map(({ user }) => user);
یک نمونه دیگر کلاسهای Model در TypeScript است. در این کلاس ما فقط متدها و فیلدهای ساده getter را داریم:
export class TransactionModel extends Model {
// some methods and fields
private get getId(): string {
return this.id;
}
private get getUser(): User {
return this.user;
}
public getPlaceholder(): string {
const user = this.getUser();
return `transaction #${this.getId()} for user: ${user.firstName} ${
user.lastName
}`;
}
}
نمونهای از Unit در React. کامپوننت سادهای که اطلاعات مربوط به کاربر را رندر میکند و دارای state داخلی است:
import React, { FC, useState } from "react";
interface Props {
user: User;
}
export const UserCard: FC<Props> = ({ user }) => {
const [isPhoneNumberShown, setIsPhoneNumberShown] = useState<boolean>(false);
const handleBtnClick = (): void => {
setIsPhoneNumberShown(true);
};
return (
<Card>
<Avatar src={user.avatarUrl} />
<table>
<tbody>
{/* some code */}
<tr>
<td>Phone number:</td>
<td>
{isPhoneNumberShown ? (
<>{user.phoneNumber}</>
) : (
<button onClick={handleBtnClick}>Show phone number</button>
)}
</td>
</tr>
</tbody>
</table>
</Card>
);
};
تستهای End-to-End
این نوع تستها (به اختصار e2e) برای تست نرمافزار به عنوان یک سیستم کلی از دید ناظر خارجی استفاده میشوند. این چه مفهومی دارد؟ در توسعه فرانت-اند اینگونه به نظر میرسد:
- شما یک تست مینویسید که مرورگر را باز میکند.
- به صفحه یا پروفایل خاصی از برنامه شما میرود.
- رابط کاربری برنامه شما را دستکاری میکند: با کلیک روی دکمهها، اسکرول کردن، نوشتن متن در فرمها و ...
نتیجه این تستها باید رفتار صحیح رابط کاربری برنامه باشد. E2E از تعامل کاربر با برنامه تقلید میکند. این تستها نمیدانند که سیستم چگونه در داخل کار میکند.
فناوریهایی که میتوانند برای نوشتن تست End-to-End در اکوسیستم TypeScript / JavaScript استفاده شوند:
- Puppeteer
- Playwright
- Cypress
تستهای یکپارچه سازی
تستهای یکپارچه سازی (که به آن تستهای ماژول نیز گفته میشود) برای تست گروهی از ماژولها که با یکدیگر در تعامل هستند، استفاده میشود. آنها تست میکنند که چگونه ماژولها به صورت جداگانه با هم کار میکنند.
در فرانت-اند یک مثال عالی از این نوع تست میتواند موردی باشد که بررسی میکند در هنگام تعامل چند تست واحد (به عنوان مثال کامپوننتهای موجود در ریاکت) با یکدیگر برنامه عملکرد خوبی دارد.
چرا تست واحد را ترجیح میدهیم؟
اکنون که در مورد چند نوع تست اطلاعات کافی داریم، بیایید در این مورد بحث کنیم که چرا باید تستهای واحد را به عنوان یک توسعه دهنده ترجیح دهیم؟ تستهای واحد مزایای بیشتری نسبت به سایر تستها دارند که عبارتند از:
- سرعت. تستهای واحد سریعتر از سایر تستها نوشته و اجرا میشوند.
- تستهای واحد میتوانند به ما نشان دهند که دقیقا کجا خطا رخ داده است. تستهای End-to-End برنامه را به عنوان یک سیستم کامل بررسی میکند و ممکن است متوجه نشوید که کدام قسمت از سیستم دارای خطا است.
- از آنجا که شما تستهای واحد را برای قسمتهای خاصی مانند ماژولها، توابع، کلاسها و کامپوننتها مینویسید، از نظر ذهنی به کد نزدیکتر هستید. همچنین برایتان به عنوان یک توسعه دهنده قابل درکتر است، چراکه با مفاهیم مشابه کد در ارتباط هستید.
ساختار تستهای واحد
مفهومی از ساختار تست واحد به نام AAA وجود دارد (Arrange، Act، Assert). ایده سادهای است، در آن شما تست واحد خود را به سه مرحله تقسیم میکنید:
فاز Arrange
این مرحلهای است که شما تست خود را قبل از فاز بعدی آماده میکنید (Act). در اینجا شما باید stubها و mockها را فراهم کنید (در ادامه این موارد را توضیح میدهیم) و این برای اجرای کدی که در تست مورد نیاز است، لازم میباشد.
در کتابخانه Jest متدهای آن شامل beforeEach، beforeAll، afterEach و afterAll است.
گاهی اوقات باید برخی از ماژولهایی را که در تست استفاده میشوند به اصطلاح mock کنید (در اینجا ما در مورد ماژولهای جاوااسکریپت صحبت میکنیم که میتوانند توسط constructهای ایمپورت شده استفاده شوند). برای این منظور میتوانید از کتابخانههایی که این ویژگی را دارند (مانند Jest) استفاده کنید، یا میتوانید از کتابخانهای استفاده کنید که فقط برای این ویژگی خاص ساخته شده است (مانند Rewire).
دادهها برای پارامترهای ورودی باید در این مرحله آماده شوند.
فاز Act
در این مرحله شما اجرای unit را مینویسید (تابع، کلاس، کامپوننت و غیره) که تست برای آن انجام شده است.
فاز Assert
این مرحلهای است که باید انتظارات از نتیجه اجرای ماژول را بنویسیم. اگر انتظارات با نتیجه یکسان باشد، تست پذیرفته میشود (سبز)، در غیر این صورت تست ناموفق است (قرمز). در این مرحله باید از برخی assertionها یا کتابخانهها برای نوشتن انتظارات استفاده کنیم. این میتواند یک کتابخانه خاص مانند Chai.js یا کتابخانهای باشد که توانایی نوشتن انتظارات مانند Jest را دارد.
Test Double
قبلتر اصطلاحاتی مانند mock و stub را ذکر کردیم. اما این اصطلاحات به چه معنی است؟ همانطور که قبلا یاد گرفتیم، تستهای واحد تست ماژولها هستند و باید ماژولها را به طور مستقل از یکدیگر تست کنند. عمدتا ماژولها دارای پارامترهای ورودی اند که برخی از دادهها را دریافت میکنند. این دادهها میتوانند خروجی ماژول دیگری باشند. اما نمیتوانیم فقط از دادههای خروجی ماژول دیگر در تست استفاده کنیم. زیرا در این صورت یک تست واحد نخواهد بود. اگر آن ماژول در داخل تغییر کند چه؟ بنابراین تست ماژول اول ناموفق میشود. مشکلی که در اینجا وجود دارد این است که تست به دلیل ماژولی که به آن وابسته نیست، شکست خواهد خورد که اصل ماژولار بودن اینگونه تستها را نقض میکند.
به همین دلیل ما باید دادههای جعلی ایجاد کنیم یا رفتار جعلی ماژول دیگری را برای استفاده از آن در پارامترهای ورودی ماژول تست شده ایجاد کنیم. برای این کار میتوانیم از تست دوبل بهره بگیریم.
Dummy Object
Dummy Object شیئی است که هیچ دادهای در داخل ندارد. این شی در تستها بیشتر شبیه مکان یاب است، نه یک شی واقعی.
نمونهای از Dummy Object استفاده از کلاس خالی است که جایگزین یک کلاس واقعی میشود. نکته مهم در اینجا کلاس خالی ساختگی است و کلاس واقعی باید از یک کلاس والد به ارث برسد، در غیر این صورت از یک رابط استفاده میکنند.
Dummy Object هنگامی مورد نیاز است که ماژولی که تست میکنیم دارای پارامتر مورد نیاز باشد اما رفتار ماژول را که بر اساس این پارامتر تعیین میشود، تست نمیکنیم. ما فقط باید ماژول را با چند داده خالی در پارامتر مورد نیاز اجرا کنیم.
در اینجا یک مثال ساده از Dummy Object آورده شده است:
import { Player } from "./Player";
export class DummyPlayer extends Player {
// ...
public getUsername() {
return "player1";
}
public getLevel() {
return 42;
}
}
نمونهای از تست با شی Dummy:
import { DummyPlayer } from "./DummyPlayer";
import { GameSession } from "./GameSession";
describe("GameSession", () => {
// ...
it("should start session with players", () => {
const player = new DummyPlayer();
const gameSession = new GameSession(player);
gameSession.start();
expect(gameSession.isStarted).toBe(true);
});
});
Fake Object
این شامل دادههای ساده شده از یک شی واقعی است که قبلا جای برخی از اشیا واقعی را میگرفت. شی fake باید شامل همان دادههای شی واقعی باشد، نه چیز دیگر.
نمونهای از Fake Object یک نمونه جعلی از کلاس پایگاه داده است که دادهها را در حافظه ذخیره میکند که برای استفاده از آن در یک تست، نیازی به خواندن اطلاعات از پایگاه داده نخواهید داشت.
یک مثال خوب برای آن، جایگزینی شی XMLHttpRequest با یک شی جعلی با استفاده از کتابخانه Sinon.js - Fake XHR and server است.
Stub
Stub شیئی است که در توابع دادههای خروجی از پیش تعریف شده را برمیگرداند. همچنین شامل قوانین خاصی مانند "وقتی پارامترها x1 و x2 هستند باید نتیجه y را برگردانیم" است. به علاوه نیازی به پارامتر نیست، یک تابع فارغ از پارامترهای موجود میتواند برخی از دادههای از پیش تعریف شده را برگرداند. دادههای از پیش تعریف شده مقادیری است که برای انجام قبولی در تست به آنها نیاز داریم.
Stubها تضمین میکنند که با تغییر ماژولها (خروجیهایی که در تست این ماژولها استفاده میشود) تست یک ماژول خاص شکست نخواهد خورد. هرچند طرف دیگر قضیه را هم باید درنظر گرفت. اگر نتایج این ماژولها تغییر کند چه؟ سپس داده واقعی (stub) در تست ماژول نخواهیم داشت.
چگونه میتوانیم از این مشکل جلوگیری کنیم؟ تایپ استاتیک میتواند در اینجا به ما کمک کند. اگر از TypeScript استفاده میکنید و یا رابط کاربری یا نوع خروجی برخی از ماژولها را مشخص کردهاید، باید در هر تستی که نوع خروجی ماژول متفاوت است، Stub را تغییر دهید.
به عنوان مثال، در Jest با استفاده از متد spyOn میتوانید stub ایجاد کنید. این stub را ایجاد میکند اما همچنین میتواند به عنوان spy مورد استفاده قرار گیرد:
import * as helpers from "./helpers";
describe("moveFiles", () => {
// ...
it("should return failed status", () => {
jest.spyOn(helpers, "moveFiles").mockReturnValue({ success: false });
expect(helpers.moveFiles([], [])).toStrictEqual({
success: false,
});
});
});
Spy
این متدی است که از توابع خاص جاسوسی میکند. spy در حال ردیابی اطلاعات توابع در مورد موارد زیر است:
- چند بار این تابع فراخوانی شده
- نتیجه فراخوانی تابع چه بوده
- با چه پارامترهایی فراخوانی انجام شده
مثالی از Jest داریم که در آن میخواهیم از تابع خاصی جاسوسی کنیم و باید در داخل تابع دیگری فراخوانی شود که تست برای آن است:
it("should call helper `checkFile`", () => {
jest.spyOn(helpers, "checkFile");
helpers.moveFiles(
[
{
name: "file 1",
ext: "txt",
path: "/home",
},
{
name: "file 1 // ",
ext: "txt",
path: "/home",
},
],
[
{
path: "/usr/etc",
},
]
);
expect(helpers.checkFile).toHaveBeenCalledTimes(2);
expect(helpers.checkFile).toHaveBeenLastCalledWith({
name: "file 1 // ",
ext: "txt",
path: "/home",
});
});
Mock
mock شیئی است که در توابع دارای قوانین خاص (یا انتظارات) به کار میرود یا فقط تابعی با رفتار از پیش تعریف شده و انتظارات از پیش تعریف شده است. با استفاده از mock میتوانیم از فراخوانیهای API و سایر عوارض جانبی جلوگیری کنیم.
خوب بیایید کل اجرای تابع را از مثال قبلی mock کنیم:
import * as helpers from "./helpers";
const file = {
name: "file 000",
ext: "md",
path: "/home",
};
const checkFile = jest.fn().mockReturnValue(true);
jest.mock("./helpers.ts", () => {
return {
moveFiles: jest.fn().mockImplementation(() => {
checkFile(file);
return {
success: true,
};
}),
};
});
describe("moveFiles", () => {
it("should call helper `checkFile`", () => {
const result = helpers.moveFiles([], []);
expect(result).toStrictEqual({
success: true,
});
expect(checkFile).toHaveBeenCalledTimes(1);
expect(checkFile).toHaveBeenLastCalledWith(file);
});
});
Fixture
نوع دیگری از تست دوبلها وجود دارد به نام fixture که بیشتر در توسعه فرانت-اند استفاده میشوند. فیکسچرها نوعی داده جعلی هستند که دادههای واقعی را از API جایگزین میکنند. به جای ارسال درخواست به یک API واقعی میتوانید از متدهایی استفاده کنید که همان دادههای API (فیکسچر) را برگرداند.
همچنین در بک-اند برای جایگزینی درخواستها به پایگاه داده واقعی استفاده میشوند. اگر به برخی از stateهای خاص پایگاه داده نیاز دارید، میتوانید ابزارهایی ایجاد کنید که دادههای یک state خاص را از آن پایگاه داده جایگزین کنید.
چگونه فیکسچرها را تولید کنیم؟ راههای مختلفی برای این کار وجود دارد. اگر در قسمت فرانت-اند کار میکنید، بک-اندی که با آن سروکار دارید فایل JSON را که براساس نوع پاسخهای API تولید شده است در اختیارتان قرار میدهد. اما زمانی که با بک-اند سروکار ندارید (به عنوان مثال این API برخی سرویسهای خارجی است)، میتوانید اسکیمهای JSON را براساس مستندات API مانند Swagger / Open API ایجاد کنید.
جمع بندی
تستهای واحد به شما کمک میکنند تا کد امنتر و موثرتری بنویسید تا بتوانید به راحتی آنها را تغییر دهید و بدون ترس از به هم ریختگی روند توسعه، آنها را بازسازی کنید. این یک راهکار طلایی نیست، اما برخی تکنیکها و روشهایی وجود دارد که میتواند به شما کمک کند تا مشکلات تست را بر طرف کنید. در مقالات بعدی در این مورد بیشتر صحبت خواهیم کرد.
دیدگاه و پرسش
در حال دریافت نظرات از سرور، لطفا منتظر بمانید
در حال دریافت نظرات از سرور، لطفا منتظر بمانید