آموزش Unit Test در ASP.NET Core

دوشنبه 8 خرداد 1396

تست واحد به سادگی تایید می کند که قطعه کدها (عمدتا توابع) آنطور که انتظار می رود کار می کنند یا خیر. این تست ها به شما این امکان را می دهند که متدها و توابع را به صورت جداگانه بررسی کنید و رفتارهای آنها را تحت شرایط و داده های مختلف تست نمایید.

آموزش Unit Test در ASP.NET Core

این مقاله در ادامه مقاله آموزش تزریق وابستگی در ASP.NET Core است. تمرکز اصلی این مقاله روی تست واحد خواهد بود. از آنجا که DI و تست واحد به صورت موازی با یکدیگر در حال گسترش هستند ما باید این موضوعات را ابتدا برای پایه ریزی آنچه که امروزه باید یاد بگیریم، پوشش دهیم. اگر در حال حاضر شما با IoC/ DI آشنا نمی باشید، بهتر است ابتدا مقاله آموزش تزریق وابستگی در ASP.NET Core را مطالعه کنید.

تست واحد چیست؟

تست واحد به سادگی تایید می کند که قطعه کدها (عمدتا توابع) آنطور که انتظار می رود کار می کنند یا خیر. این تست ها به شما این امکان را می دهند که متدها و توابع را به صورت جداگانه بررسی کنید و رفتارهای آنها را تحت شرایط و داده های مختلف تست نمایید.

چرا برای انجام آزمون واحد خودمان را به زحمت بیاندازیم؟

یک دلیل بزرگ این است که تست های واحد، کار را تغییر کد ها بسیار آسان تر می کند در حالی که ما اطمینان داریم که رفتار آن قطعه کد تغییر نخواهد کرد. این بدان معنی است که وقتی یک قطعه کد تست شده داریم، خیلی ساده تر می توانیم آن قطعه کد را باز نویسی کنیم و اگر در زمانی که داریم این کار را انجام می دهیم خطایی ایجاد شود، سریعا متوجه خواهیم شد. این عالی است زیرا به این معنی است که در هنگام توسعه نرم افزار اشکالات نمی توانند به صورت مخفیانه به برنامه ما وارد شوند. اگر اشکال در مراحل اولیه برنامه نویسی شناسایی شود، هزینه تعمیر و رفع آن کمتر است.

تست های واحد، طراحی بهتری را ایجاد می کنند.

نوشتن آزمون واحد تاثیر زیادی بر روی طراحی نرم افزار ما دارد زیرا با نوشتن آزمون های واحد نقاط حساس و خطرناک نرم افزار مشخص می شوند. اگر در برنامه بخش هایی باشد که جوری طراحی شده باشند که قابلیت تست نداشته باشند، هم برای همان بخش هم برای دیگر بخش ها که به این بخش وابسته هستد خیلی خطرناک است.

هنگامی که شما در ذهنتان در حال طراحی کلاس هایی با تست واحد هستید، شما مجبور به شکستن برنامه خود به واحدهای مجزایی که قابلیت تست داشته باشند، می شوید. در برخی موارد، شما تقریبا مجبور نیستید که اصلا در مورد طراحی فکر کنید.

 این تست ها شما را هدایت می کنند که اطمینان یابید توابع و کلاس های برنامه دارای رفتار درستی هستند و این کلاس ها به درستی به کمک اینترفیس ها و تزریق وابستگی ها از یکدیگر مجزا طراحی شده اند. این کارها دارای یک اثر جانبی است که باعث می شود اصل "هر مولفه یک مسئولیت" در طراحی ما استفاده شود.

آنچه در مورد توسعه تست محور مطرح است، چیست؟

ما معمولا کدها را در بخش های کوچک به صورت موازی با تست ها می نویسیم و این شیوه را تفکر تست محور می نامیم. توسعه تست محور (TDD) یک فرایند متفاوت است که در آن هر تست قبل از قطعه کد آن نوشته شده است. این تست ها مانند تنظیمات عمل می کنند به نحوی که شما ابتدا برای یک شی یا تابعی که وجود ندارد تست می نویسید و این تست با شکست مواجه می شود، سپس شما باید به همانقدر که نیاز است تا تست pass شود کد نویسی کنید. بنابراین در حالی که TDD اغلب یک محرک برای تست واحد است ولی تست واحد به TDD نیاز ندارد.

اضافه کردن یک پروژه تست واحد

یک روش خوب برای تست واحد در دات نت این است که تست ها در یک کلاس کاملا مجزا از برنامه باشد. بنابراین، برای شروع، یک .NET Core Class Library به برنامه اضافه می کنیم.(از همان برنامه ایی که در مقاله آموزش تزریق وابستگی در ASP.NET Core ساخته شده بود استفاده می شود)

ما نیاز به اضافه کردن یک چارچوب تست واحد داریم که از xUnit  استفاده می کنیم که از .net core  پشتیبانی می کند و قابلیت استفاده به صورت built-in را در visual studio 2015 دارد.

 NUnit، یکی دیگر از چارچوب های بسیار محبوب است و تفاوت هایی بین این دو وجود دارد ولی دنبال اینکه کدام بهتر است نباشید، چراکه هر دوی آنها مزیت های خودشان را دارند. یک کتابخانه اضافی برای تقلید برخی از رفتارها و داده ها در تست های واحد ما مورد نیاز است. این مساله را می توان به عنوان تقلید یا جعل نام برد. بزودی مشاهده خواهیم کرد که چگونه استفاده از moq برای تقلید رفتار یک رابط در تست ما صورت می گیرد و این یک ساختار قدرتمند در تست واحد است. در نهایت، ما نیاز به اضافه کردن یک reference در پروژه تست واحد داریم، تا بتوانیم به کلاس هایی که می خواهیم تست کنیم، دسترسی داشته باشیم.

json پروژه باید مشابه کد زیر باشد.

{
  "version": "1.0.0-*",

  "dependencies": {
    "moq.netcore": "4.4.0-beta8",
    //http://stackoverflow.com/questions/37288385/moq-netcore-failing-for-net-core-rc2
    "System.Diagnostics.TraceSource": "4.0.0-rc2-24027",
    "NETStandard.Library": "1.6.0",
    "xunit": "2.2.0",
    "dotnet-test-xunit": "2.2.0-preview2-build1029",
    "ASPNetCoreDIAndUnitTesting": "1.0.0-*"
  },

  "testRunner": "xunit",

  "frameworks": {
    "netcoreapp1.0": {
      "imports": [ "netcoreapp1.0", "portable-net45+win8", "dnxcore50" ],
      "dependencies": {
        "Microsoft.NETCore.App": {
          "version": "1.1.0",
          "type": "platform"
        }
      }
    }
  }
}

از بخش های مهم کد بالا بخش "dependencies:{...}" است که در چارچوب xUnit آمده است و بخش مهم دیگر بخش اجرا کنند تست است "testRunner": "xunit"  که به معنای استفاده ما از کتابخانه xUnit است.

نوشتن تست های واحد

خوب، ما برخی از نظریه های خوب در مورد اینکه تست واحد چیست و چرا باید از آن استفاده کنیم را مرور کردیم.

 ما پروژه خود را با توجه به چارچوب xUnit ساخته ایم به طوری که کد برنامه ما قابل تست باشد. حالا وقت نوشتن تست ها است.

اولین تست ما در مورد کلاس TeamStatCalculator در داخل برنامه است و به خصوص متد GetTotalGoalsForSeason()  که دارای پیچیدگی های فراوانی است.

public class TeamStatCalculator
{
  private readonly ITeamStatRepository _teamStatRepository;

  public TeamStatCalculator(ITeamStatRepository teamStatRepository)
  {
     _teamStatRepository = teamStatRepository;
  }

  public int GetTotalGoalsForSeason(int seasonId)
  {
    // get all the team stats for the given season
    var teamStatsBySeason = _teamStatRepository
    .FindAll(ts => ts.SeasonId == seasonId);

    // sum and return the total goals
    return teamStatsBySeason.Sum(ts => ts.GoalsFor);
  }
}

با شکستن این متد به قطعه کدهای کوچک تر، مشاهده می کنیم که در واقع در حال انجام یک کوئری ساده روی _teamStatRepository  که این متد با گرفتن مقدار id یک فصل، وضعیت بازیکنان یک تیم در آن فصل را بر میگرداند و سپس به کمک دستورات linq و با استفاده از دستور sum تعداد گل هایی که توسط بازیکنان این تیم در فصل به ثمر رسیده مشخص می شود.

یک نکته قابل توجه در این متد این است که _teamStatRepository یک وابستگی است که تزریق این وابستگی توسط متد سازنده کلاس انجام می شود. در واقع، اینrepository  می تواند با یک پایگاه داده، یک فایل یا api برای دریافت داده های خود ارتباط برقرار کند اما نکته این است که این امر مهمی نمی باشد زیرا ما جزئیات اجرای آن را در داخل اینترفیس ITeamStatRepository پنهان نموده ایم. ما صرفا با رفتار های این کلاس مواجه هستیم و به جزئیات پیاده سازی آن کاری نخواهیم داشت.

نکته قابل ذکر دیگر این است که اگر تستی برای یک فایل می نویسید یا مثلا برای بررسی اتصال به پایگاه داده یا کارهایی که روی شبکه انجام می شود، به این نوع تست ها، تست یکپارچگی می گویند که این نوع تست ها با تست های قبلی متفاوت هستند.

تست مربوط به این متد را در زیر می بینید.

[Fact]
public void GetTotalGoalsForSeason_returns_expected_goal_count()
{
  // ARRANGE 
  var mockTeamStatRepo = new Mock();

  // setup a mock stat repo to return some fake data in our target method
  mockTeamStatRepo
 .Setup(mtsr => mtsr.FindAll(It.IsAny<Func<TeamStatSummary, bool>>()))
 .Returns(new List<TeamStatSummary>
     {
        new TeamStatSummary {SeasonId = 1,Team = "team 1",GoalsFor=1},
        new TeamStatSummary {SeasonId=1,Team = "team 2",GoalsFor=2},
        new TeamStatSummary {SeasonId = 1,Team = "team 3",GoalsFor=3}
     });

  // create our TeamStatCalculator by injecting our mock repository
  var teamStatCalculator = new TeamStatCalculator(mockTeamStatRepo.Object);

  // ACT - call our method under test
  var result = teamStatCalculator.GetTotalGoalsForSeason(1);
 
  // ASSERT - we got the result we expected - our fake data has 6 goals
  we should get this back from the method
  Assert.True(result==6);
}

ساختار تست واحد

تست واحد ما از رویکرد "AAA" پیروی می کند که در آن ما:

Arrange - مرتب سازی بر اساس آنچه نیاز است.

Act - اجرای تست (مثلا فراخوانی یک متد و دریافت خروجی آن متد).

Assert - اثبات کردن، که نتیجه خروجی متد را با نتیجه مورد انتظار مقایسه می کند.

ما می توانیم مجموعه ایی از تست ها را در بخش مرتب سازی مشاهده کنیم. در اینجا ما برای متد IteamStatRepository یک تقلید کننده(mock) می سازیم و رفتارها و داده هایی را برای متد FindAll() تعریف می کنیم. سپس بعد از ایجاد شدن شی مخزن ساختگی (تقلیدی)، آن را به TeamStatCalculator تزریق می کنیم.

 پس از اینکه تنظیمات ما کامل انجام شد، بخش عمل شروع می شود و متد GetTotalGoalsForSeason(1) را فراخوانی می کند تا نتیجه خروجی آن را دریافت کند. این عمل صرفا یک خط کوچک در تست ما است ولی اگر شما تست را در زمان debugging اجرا کنید می توانید قدرت واقعی آن را مشاهده کنید. اگر شما این گام را از طریق GetTotalGoalsForSeason() انجام داده باشید خواهید دید که شی repository ساختگی ما، داده هایی را بازمی گرداند که توسط خود ما در تست ها ساخته شده اند. این یک نمایش کامل برای نشان دادن خصوصیات IoC/DI است!. ما به طور کامل کنترل معکوس برای ایجاد یک repository را انجام داده ایم و می توانیم از آن برای هر گونه تستی که بخواهیم استفاده کنیم.

در نهایت، در بخش اثبات کردن، ما می دانیم که در تست هایی که نوشته ایم 6 هدف داشتیم و این متد باید صحت عملکرد کدها را در خروجی نمایش دهد.

با اجرای تست در VS test explorer ، یک تیک سبز به معنای pass شدن تست نمایش داده می شود.

در روش دیگر، ما می توانیم تست ها را از خط فرمان (command line) با استفاده از ابزار CLI جدید اجرا کنیم. در این پروژه به سادگی با اجرای دستور dotnet test  در خط فرمان اجرا کننده تست فراخوانی می شود.

کنترلرهای تست واحد

کنترلر ها بخش بزرگی از یک برنامه ASP.NET Core هستند، بنابراین پوشش این کنترلر ها بسیار مهم است تا اطمینان حاصل شود که آنها رفتار مورد انتظار را انجام می دهند. ما معمولا می خواهیم که در کنترلر ها از کدهایی که به منطق تجارت یا دسترسی به داده ها مربوط است استفاده نشود، این کدها باید در داخل کلاس مربوط به خودش نوشته شود و هدف ما این است که رفتار کنترلر را با توجه به ورودی که به کنترلر داده می شود و خروجی که کنترلر بعد از انجام عملیات های داخلی به ما می دهد بررسی کنیم.

حال وقت آن است که یک تست برای متد Index()  که در HomeController  وجود دارد بنویسیم.

public IActionResult Index([FromServices] IGameRepository gameRepository)
{
  var model = new IndexViewModel
  {
    Players = _playerRepository.GetAll().ToList(), // constructor injected
    Games = gameRepository.GetTodaysGames().ToList() // parameter injected
  };

  return View(model);
}

تست مرتبط برای این عمل ...

[Fact]
public void Index_returns_viewresult_with_list_of_players_and_games()
{
  // ARRANGE 
  var mockPlayerRepo = new Mock();
          
  mockPlayerRepo.Setup(mpr => mpr.GetAll()).Returns(new List
  {
     new Player {Name = "Sidney Crosby"},
     new Player {Name="Patrick Kane"}
  });

 var mockGameRepo = new Mock();

 mockGameRepo.Setup(mpr => mpr.GetTodaysGames()).Returns(new List
 {
   new Game {
            HomeTeam = "Montreal Canadiens",
            AwayTeam = "Toronto Maple Leafs",
            Date = DateTime.Today},
   new Game {
            HomeTeam = "Calgary Flames",
            AwayTeam = "Vancouver Canucks",
            Date = DateTime.Today},
   new Game {
            HomeTeam = "Los Angeles Kings",
            AwayTeam = "Anaheim Ducks",
            Date = DateTime.Today},
 });

 // player repository is injected through constructor
 var controller = new HomeController(mockPlayerRepo.Object);

// ACT 
// game repository is injected through action parameter
var result = controller.Index(mockGameRepo.Object); 

// ASSERT our action result and model
var viewResult = Assert.IsType(result);
var model = Assert.IsAssignableFrom(viewResult.ViewData.Model);
Assert.Equal(2, model.Players.Count);
Assert.Equal(3, model.Games.Count);
}

هیچ چیز اساسا در این تست از آنچه که ما در تست TeamStatCalculator انجام دادیم متفاوت نمی باشد. ما از رویکرد "aaa" استفاده می کنیم. برای برخی از وابستگی ها مثل PlayerRepository و GameRepository   یک مقلد می سازیم و سپس آنها را در کنترلر تزریق می کنیم و متد index را فراخوانی می کنیم. بزرگترین تفاوت در بخش  اثبات کردن است که ما نیاز داریم تا ViewResult و model را بررسی کنیم تا ببینیم نتیجه خروجی با نتیجه مورد انتظار تطابق دارد یا خیر. در کل می توان گفت ساختار نوشتن تست برای منطق برنامه و کنترلر های بسیار شبیه هم هست.

آموزش asp.net mvc

برنامه نویسان

نویسنده 3355 مقاله در برنامه نویسان

کاربرانی که از نویسنده این مقاله تشکر کرده اند

در صورتی که در رابطه با این مقاله سوالی دارید، در تاپیک های انجمن مطرح کنید