استفاده از اینترفیس Go برای نوشتن کد قابل تست — به زبان ساده
اینترفیسها انتزاعهایی هستند که رفتار یک نوع خاص را تعریف میکنند، اما جزییات شیوه پیادهسازی آن رفتار را توصیف نمیکنند. برای مثال میتوانید صندوقدار بانک را تصور کنید که امکان دریافت و پرداخت پول از طریق آنها وجود دارد. اینکه صندوقدار یک کارمند بانک یا یک ATM در محلهتان باشد، فرقی در خروجی ندارد و شما همیشه پول دریافت میکنید. با این که بین اینترفیس انسانی و ماشین تفاوتهایی وجود دارد، ولی در هر حال باز هم میتوانید پول دریافت یا پرداخت کنید. بسیاری از زبانهای شیءگرا نمادهایی از اینترفیسها دارند. اینترفیس Go به طور خاص جالب و متمایز است، زیرا به طور صریح تعریف میشود.
این بدان معنی است که به جای توصیف آن شیء، مشغول ایجاد پیادهسازیهایی از یک اینترفیس هستید و کامپایلر Go آنها را محاسبه میکند. اگر شیء شما شامل همه متدهای تعریفشده از سوی اینترفیس باشد، Go درک میکند که شیء مورد نظر آن اینترفیس را پیادهسازی کرده و میتواند هر جا که اینترفیس استفاده شده به جای آن مورد استفاده قرار گیرد.
اینک سؤال این است که این وضعیت چرا مفید است و چگونه میتوان از اینترفیسها برای نوشتن کد قابل تست استفاده کرد.
مثال: استفاده از کتابخانههای اکسترنال
یک سناریوی رایج برای نشان دادن کاربرد اینترفیسها، تست کردن کدی است که از کتابخانه کلاینت اکسترنال برای انجام کاری استفاده میکند. در این مثال که ما بررسی میکنیم، دادهها از یک API خیالی وب دریافت میشوند.
فرض کنید یک پکیج به نام external داریم. این پکیج کتابخانهای است که برای تعامل با وبسرویس اکسترنال نوشته شده است. این پکیج میتواند یک شیء Client را با متدهایی برای تعامل با API اکسپورت کند. تصور کنید یک متد GetData در این مثال وجود دارد که تنها میتواند دادهها را بازگشت دهد، اما میتواند یک درخواست وب نیز ایجاد کند و از نظر تئوریک در صورت وجود یک پیادهسازی واقعی خطایی بازگشت دهد.
1package external
2
3type Client struct{}
4
5func NewClient() Client {
6 return Client{}
7}
8
9func (c Client) GetData() (string, error) {
10 return "data", nil
11}
در کد فوق پکیج foo را داریم. این پکیج کدی است که مینویسیم و امیدواریم از Client برای دریافت دادهها از API اکسترنال استفاده کنیم و سپس کارهایی روی آن انجام دهیم. ما در این پیادهسازی دو حالت ممکن برای خطا داریم، یکی در زمانی که است GetData خطایی بازگشت دهد که دلیل احتمالی آن شکست خوردن درخواست وب است و دیگری این است که دادههای بازگشت یافته آن چیزی نباشند که ما انتظار داریم و از این رو نتوانیم آنها را پردازش کنیم. هر دو مسیر منجر به این میشود که تابع Controller خطایی بازگشت دهد:
1package foo
2
3import (
4 "errors"
5
6 "interfaces/external"
7)
8
9func Controller() error {
10 externalClient := external.NewClient()
11 fromExternalAPI, err := externalClient.GetData()
12 if err != nil {
13 return err
14 }
15 // do some things based on data from external API
16 if fromExternalAPI != "data" {
17 return errors.New("unexpected data")
18 }
19 return nil
20}
اکنون به بررسی چگونگی تست تابع Controller میپردازیم. دو نوع تست مقدماتی میتوانیم داشته باشیم که یکی موفقیت تابع را بررسی میکند و دیگری دو حالت شکست را مورد توجه قرار میدهد. مشکل اینجا است که نمیتوانیم رفتار API اکسترنال را تحت تأثیر قرار دهیم و از این رو نمیتوانیم GetData را ملزم به موفقیت یا شکست بکنیم.
1package foo_test
2
3import (
4 "testing"
5
6 "interfaces/foo"
7)
8
9func TestController_Success(t *testing.T) {
10 err := foo.Controller()
11 if err != nil {
12 t.FailNow()
13 }
14}
15
16func TestController_Failure(t *testing.T) {
17 // we want this to error but we can't get it to
18 // because we can't easily stub the external.Client struct
19 err := foo.Controller()
20 if err == nil {
21 // this test will fail :(
22 t.FailNow()
23 }
24}
تست فوق TestController_Success موفق میشود، اما TestController_Failure شکست میخورد، زیرا ما برای تست حالتهای شکست ناموفق بودهایم. این حالت تأیید میشود و در گزارش coverage به تصویر کشیده است.
نه تنها حالتهای شکست ما پوشش نیافتهاند بلکه تستهای Unit نیز هم اینک غیرقطعی هستند، چون شکست یا موفقیت API اکسترنال چنین حالتی دارد. ما به روشی نیاز داریم که رفتار GetData را در کد خود تثبیت کنیم و بدین ترتیب بتوانیم خروجی آن را در طی تستهای unit دستکاری کنیم و این همان کاری است که اینترفیسها به خوبی انجام میدهند.
استفاده از اینترفیسها برای فعالسازی Stubbing
اگر بتوانیم اینترفیسی تعریف کنیم که شیء Client کتابخانه اکسترنال را تأمین کند، میتوانیم از اینترفیس در کد خود به جای آن استفاده کنیم. بدین ترتیب میتوانیم یک شیء کلاینت را در زمان تست کردن جعل کنیم و در زمان عملیات واقعی از نسخه اصلی استفاده کنیم.
در زبانهای دیگر این کار نیازمند آن است که کد کتابخانه اکسترنال را دستکاری کنیم تا صراحتاً بیان کنیم که Client اینترفیس جدید ما را پیادهسازی میکند، اما از آنجا که اینترفیسهای Go صراحتاً تأمین میشوند، کامپایلر این نکته را خواهد دانست.
بنابراین اینترفیس IExternalClient را تعریف میکنیم که متدهایی که در Controller خود مورد استفاده قرار میدهیم را تعیین کرده است. تابع کنترلر را طوری تغییر میدهیم که یک نوع اینترفیس IExternalClient به عنوان پارامتر بگیرد. بقیه تابع کنترلر همانند قبل عمل میکند یعنی متد GetData را از اینترفیس به جای پیادهسازی Client اکسترنال خاص فراخوانی میکند.
1package foo
2
3import (
4 "errors"
5)
6
7type IExternalClient interface {
8 GetData() (string, error)
9}
10
11func Controller(externalClient IExternalClient) error {
12 fromExternalAPI, err := externalClient.GetData()
13 if err != nil {
14 return err
15 }
16 // do some things based on data from external API
17 if fromExternalAPI != "data" {
18 return errors.New("unexpected data")
19 }
20 return nil
21}
اکنون نگاهی به میزان سادگی تست کردن متد Controller میپردازیم. ما میتوانیم اینترفیس IExternalClient را صرفاً با پیادهسازی متد GetData روی شیء MockClient پیادهسازی کنیم، اما در مورد پیادهسازی MockClient باید آن چه را میخواهیم هم بازگشت دهیم. سپس پیادهسازی خود از IExternalClient را به Controller در تستهای خود میدهیم. میتوان از MockClient برای بازگشت مقادیر مختلف برای نتیجه GetData استفاده کنیم و از FailingClient برای واداشتن به بازگشت خطا بهره بگیریم.
1package foo_test
2
3import (
4 "errors"
5 "testing"
6
7 "interfaces/foo"
8)
9
10type MockClient struct {
11 GetDataReturn string
12}
13
14func (mc MockClient) GetData() (string, error) {
15 return mc.GetDataReturn, nil
16}
17
18func TestController_Success(t *testing.T) {
19 err := foo.Controller(MockClient{"data"})
20 if err != nil {
21 t.FailNow()
22 }
23}
24
25type FailingClient struct{}
26
27func (fc FailingClient) GetData() (string, error) {
28 return "", errors.New("oh no")
29}
30
31func TestController_Failure(t *testing.T) {
32 // test failure of GetData()
33 err := foo.Controller(FailingClient{})
34 if err == nil {
35 t.FailNow()
36 }
37 // test unexpected data returned from GetData()
38 err = foo.Controller(MockClient{"not data"})
39 if err == nil {
40 t.FailNow()
41 }
42}
اینک به سادگی میتوانیم همه شاخههای تابع Controller را مدیریت کنیم و بهروزرسانی گزارش پوشش نیز این نکته را نشان میدهد:
چنان که میبینید، نوشتن و استفاده از اینترفیسها میتواند انعطافپذیری و تستپذیری کد را افزایش دهد. از این رو میتواند یک راهحل آسان برای تثبیت وابستگیهای اکسترنال باشد.
کتابخانههای استاندارد Go استفاده گستردهای از اینترفیسها دارند. مثالهای مناسبی را میتوانید در پکیجهایی مانند io (+) و net/http (+) ببینید.
اگر این مطلب برای شما مفید بوده است، آموزشهای زیر نیز به شما پیشنهاد میشوند:
- مجموعه آموزشهای برنامهنویسی
- آموزش توسعه وب با زبان برنامه نویسی Go
- مجموعه آموزشهای دروس علوم و مهندسی کامپیوتر
- آشنایی با دستور Vet در Go — از صفر تا صد
- پکیج های زبان برنامه نویسی Go — از صفر تا صد
==