تعلّم التطوير الموجّه بالاختبارات باستخدام اختبارات التكامل في ‎.NET 5.0

دقائق القراءة: 11

مقدمة: ما هو التطوير الموجّه بالاختبارات ولماذا يهم؟

يُعدّ Test-Driven Development أو TDD من أكثر مفاهيم تطوير البرمجيات إثارة للنقاش. فبعض المطورين يراه منهجية أساسية لرفع جودة المنتج، بينما يعتبره آخرون عبئاً إضافياً إذا لم تكن المتطلبات واضحة. الفكرة الجوهرية بسيطة: اكتب الاختبار أولاً، ثم اكتب أقل قدر ممكن من الكود لتمرير الاختبار، وبعدها حسّن البنية الداخلية للتطبيق دون كسر السلوك المتوقع.

في هذا الدليل سنطبّق المفهوم عملياً عبر بناء مثال واقعي لنظام إدخال مرضى في مستشفى، مع التركيز على اختبارات التكامل داخل بيئة .NET 5.0. كما سنستخدم إطار xUnit لكتابة الاختبارات، لأنه مرن وشائع في مشاريع ASP.NET Core.

التطوير الموجه بالاختبارات باستخدام اختبارات التكامل في دوت نت 5

لماذا نستخدم اختبارات التكامل مع TDD؟

عند الحديث عن TDD تظهر عدة أنواع من الاختبارات، مثل:

  • اختبارات الوحدة Unit Tests
  • اختبارات التكامل Integration Tests
  • اختبارات النظام System Tests
  • اختبارات قبول المستخدم UAT

في تطبيقات الويب المبنية على ASP.NET Core، تمنحك اختبارات التكامل ثقة عالية لأنها تختبر تدفقاً حقيقياً نسبياً: الطلب HTTP، والمتحكم Controller، والتحقق من البيانات، والاتصال بقاعدة البيانات داخل بيئة معزولة. وهذا يجعلها مناسبة جداً لتطبيق عملي مثل نظام حجز غرف المرضى.

متطلبات المشروع: نظام إدخال مرضى لمستشفى

نجاح منهجية TDD يرتبط مباشرة بوضوح نطاق العمل. لذلك نبدأ بتحديد المتطلبات الوظيفية بدقة قبل كتابة أي اختبار.

المتطلبات التجارية الأساسية

  • يحتوي المستشفى على غرف من نوع ICU وPremium وGeneral.
  • غرف ICU وPremium تستوعب مريضاً واحداً فقط.
  • غرف General تستوعب مريضين.
  • لكل غرفة رقم أو هوية مميزة.
  • عند إدخال المريض يجب حفظ: الاسم، العمر، الجنس، ورقم الهاتف.
  • يمكن البحث عن المريض بالاسم أو رقم الهاتف.
  • لا يجوز إدخال نفس المريض إلى أكثر من سرير في الوقت نفسه طالما أنه ما يزال منوّماً.
  • لا يمكن قبول مريض جديد إذا كانت جميع الغرف مشغولة.

قواعد التحقق من النماذج

لدينا نموذجان رئيسيان يجب التحقق منهما: Patient وRoom.

  • عمر المريض بين 0 و150.
  • طول الاسم بين حرفين و40 حرفاً.
  • الجنس يجب أن يكون Male أو Female أو Other.
  • رقم الهاتف يجب أن يتكون من أرقام فقط، ويتراوح طوله بين 7 و12 خانة.
  • نوع الغرفة يجب أن يكون ICU أو Premium أو General.

حالات الاختبار التي سنبنيها

بما أن المثال عبارة عن تطبيق CRUD بسيط، فإن معظم الاختبارات ستكون من نوع اختبارات التكامل.

حالات اختبار المرضى

  • اختبار جميع قواعد التحقق الخاصة بنموذج المريض.
  • منع إدخال نفس المريض مرتين.
  • منع إنهاء خروج نفس المريض مرتين.
  • منع إدخال المريض نفسه إلى عدة غرف في الوقت نفسه.
  • التحقق من إمكانية البحث بالاسم ورقم الهاتف.

إعداد مشروع TDD في .NET 5.0

بعد تحديد المتطلبات وحالات الاختبار، نبدأ بتجهيز بنية المشروع. شغّل الأوامر التالية من الطرفية:

mkdir TDD
cd TDD
dotnet new sln
dotnet new webapi --name TDD
dotnet new xunit --name TDD.Tests
cd TDD
dotnet add package Microsoft.EntityFrameworkCore --version 5.0.5
cd ../TDD.Tests
dotnet add reference ../TDD/TDD.csproj
dotnet add package Microsoft.EntityFrameworkCore --version 5.0.5
dotnet add package Microsoft.AspNetCore.Hosting --version 2.2.7
dotnet add package Microsoft.AspNetCore.Mvc.Testing --version 5.0.5
dotnet add package Microsoft.EntityFrameworkCore.InMemory --version 5.0.5
cd ..
dotnet sln add TDD/TDD.csproj
dotnet sln add TDD.Tests/TDD.Tests.csproj
code .

هذا السكربت ينشئ ملف الحل TDD.sln، ثم مشروع الواجهة البرمجية TDD ومشروع الاختبارات TDD.Tests، وبعد ذلك يضيف الاعتماديات اللازمة لكل مشروع.

فكرة اختبارات التكامل: محاكاة التطبيق داخل الذاكرة

على خلاف بعض اختبارات الوحدة التي تعتمد على Mocking، فإن اختبارات التكامل تهدف إلى اختبار مكوّنات حقيقية من التطبيق أثناء عملها معاً. لهذا سنستخدم TestServer عبر فئة WebApplicationFactory لتشغيل التطبيق داخل الذاكرة بدون الحاجة إلى خادم فعلي.

إنشاء Custom WebApplicationFactory

في مشروع TDD.Tests أنشئ ملفاً باسم PatientTestsDbWAF.cs وضع داخله الكود التالي:

using System.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore;

namespace TDD.Tests
{
    public class PatientTestsDbWAF<TStartup> : WebApplicationFactory<TStartup> where TStartup : class
    {
        protected override IWebHostBuilder CreateWebHostBuilder()
        {
            return WebHost.CreateDefaultBuilder()
                .UseStartup<TStartup>();
        }

        protected override void ConfigureWebHost(IWebHostBuilder builder)
        {
            builder.ConfigureServices(async services =>
            {
                // Remove the app's DbContext registration.
                var descriptor = services.SingleOrDefault(
                    d => d.ServiceType == typeof(DbContextOptions<DataContext>));

                if (descriptor != null)
                {
                    services.Remove(descriptor);
                }

                // Add DbContext using an in-memory database for testing.
                services.AddDbContext<DataContext>(options =>
                {
                    // Use in memory db to not interfere with the original db.
                    options.UseInMemoryDatabase("PatientTestsTDD.db");
                });
            });
        }
    }
}

الفكرة هنا أننا نحذف تسجيل DbContext الخاص بالتطبيق الحقيقي، ثم نستبدله بقاعدة بيانات من نوع InMemory حتى لا نؤثر على قاعدة البيانات الأصلية أثناء الاختبار.

إنشاء طبقة البيانات DataContext

في مشروع TDD أنشئ ملف DataContext.cs كما يلي:

using Microsoft.EntityFrameworkCore;

namespace TDD
{
    public class DataContext : DbContext
    {
        public DataContext(DbContextOptions options) : base(options)
        {
        }

        // For storing the list of patients and their state
        public DbSet<Patient> Patient { get; set; }

        // For the storying the rooms along with their types and capacity
        public DbSet<Room> Room { get; set; }

        // For logging which patients are currently admitted to which room
        public DbSet<RoomPatient> RoomPatient { get; set; }
    }
}

هذا السياق مسؤول عن إدارة الجداول الخاصة بالمرضى والغرف والعلاقة بينهما.

بناء النماذج الأساسية للتطبيق

نموذج المريض Patient

أنشئ ملف Patient.cs:

using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace TDD
{
    public class Patient
    {
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public int Id { get; set; }
        public String Name { get; set; }
        public String PhoneNumber { get; set; }
        public int Age { get; set; }
        public String Gender { get; set; }
    }
}

نموذج الغرفة Room

أنشئ ملف Room.cs:

using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace TDD
{
    public class Room
    {
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public int Id { get; set; }
        public String RoomType { get; set; }
        public int CurrentCapacity { get; set; }
        public int MaxCapacity { get; set; }
    }
}

نموذج الربط بين المريض والغرفة RoomPatient

أنشئ ملف RoomPatient.cs:

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace TDD
{
    public class RoomPatient
    {
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public int Id { get; set; }

        [Required]
        public int RoomId { get; set; }

        [ForeignKey("RoomId")]
        public Room Room { get; set; }

        [Required]
        public int PatientId { get; set; }

        [ForeignKey("PatientId")]
        public Patient Patient { get; set; }
    }
}

بعد هذه الخطوة، احذف الملفات الافتراضية WeatherForecast.cs وWeatherForecastController.cs من المشروع لأنها لم تعد ضرورية.

بعد ذلك شغّل الأمر التالي:

cd TDD.Tests
dotnet test

إذا كان كل شيء صحيحاً فسترى نتيجة نجاح أولية تؤكد أن بيئة الاختبار جاهزة.

نجاح أول اختبار بعد إعداد بيئة اختبارات التكامل في دوت نت

إنشاء متحكم المرضى لاختبار التحقق من النموذج

لا يوفّر .NET طريقة مباشرة لاختبار النموذج بمعزل عن واجهة التعامل البرمجية في هذا السيناريو، لذلك سننشئ متحكماً بسيطاً ونجعل الاختبارات تستدعيه فعلياً.

أنشئ ملف PatientController.cs داخل مجلد Controllers في مشروع TDD:

using Microsoft.AspNetCore.Mvc;

namespace TDD.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class PatientController : Controller
    {
        [HttpPost]
        public IActionResult AddPatient([FromBody] Patient Patient)
        {
            // TODO: Insert the patient into db
            return Created("/patient/1", Patient);
        }
    }
}

هذا المتحكم مبدئي فقط، وسنطوره تدريجياً مع تطور الاختبارات، وهو جوهر أسلوب TDD.

اختبارات التحقق من النموذج: ابدأ بالحالة الفاشلة

أول مبدأ عملي في TDD هو كتابة اختبار يفشل أولاً. أنشئ ملف PatientTests.cs في مشروع TDD.Tests واحذف الملف الافتراضي UnitTest1.cs، ثم أضف الكود التالي:

using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Xunit;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Mvc.Testing;
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;

namespace TDD.Tests
{
    public class PatientTests : IClassFixture<PatientTestsDbWAF<Startup>>
    {
        // HttpClient to call our api's
        private readonly HttpClient httpClient;
        public WebApplicationFactory<Startup> _factory;

        public PatientTests(PatientTestsDbWAF<Startup> factory)
        {
            _factory = factory;
            // Initiate the HttpClient
            httpClient = _factory.CreateClient();
        }

        [Theory]
        [InlineData("Test Name 2", "1234567891", 20, "Male", HttpStatusCode.Created)]
        [InlineData("T", "1234567891", 20, "Male", HttpStatusCode.BadRequest)]
        [InlineData("A very very very very very very loooooooooong name", "1234567891", 20, "Male", HttpStatusCode.BadRequest)]
        [InlineData(null, "1234567890", 20, "Invalid Gender", HttpStatusCode.BadRequest)]
        [InlineData("Test Name", "InvalidNumber", 20, "Male", HttpStatusCode.BadRequest)]
        [InlineData("Test Name", "1234567890", -10, "Male", HttpStatusCode.BadRequest)]
        [InlineData("Test Name", "1234567890", 20, "Invalid Gender", HttpStatusCode.BadRequest)]
        [InlineData("Test Name", "12345678901234444", 20, "Invalid Gender", HttpStatusCode.BadRequest)]
        public async Task PatientTestsAsync(String Name, String PhoneNumber, int Age, String Gender, HttpStatusCode ResponseCode)
        {
            var scopeFactory = _factory.Services;
            using (var scope = scopeFactory.CreateScope())
            {
                var context = scope.ServiceProvider.GetService<DataContext>();
                // Initialize the database, so that
                // changes made by other tests are reset.
                await DBUtilities.InitializeDbForTestsAsync(context);

                // Arrange
                var request = new HttpRequestMessage(HttpMethod.Post, "api/patient");
                request.Content = new StringContent(JsonSerializer.Serialize(new Patient
                {
                    Name = Name,
                    PhoneNumber = PhoneNumber,
                    Age = Age,
                    Gender = Gender
                }), Encoding.UTF8, "application/json");

                // Act
                var response = await httpClient.SendAsync(request);

                // Assert
                var StatusCode = response.StatusCode;
                Assert.Equal(ResponseCode, StatusCode);
            }
        }
    }
}

الميزة المهمة في استخدام السمة [Theory] أنها تسمح لك بتمرير مدخلات متعددة لنفس الاختبار، مما يقلل التكرار ويجعل التحقق من السيناريوهات المتعددة أكثر أناقة.

تهيئة قاعدة البيانات قبل كل اختبار

لضمان استقلالية الاختبارات، نستخدم فئة مساعدة تعيد قاعدة البيانات إلى حالتها الابتدائية قبل تشغيل كل اختبار.

أنشئ ملف DBUtilities.cs داخل مشروع TDD.Tests:

using System.Threading.Tasks;

namespace TDD.Tests
{
    // Helps to initialise the database either from the WAF for the first time
    // Or before running each test.
    public class DBUtilities
    {
        // Clears the database and then,
        //Adds 1 Patient and 3 different types of rooms to the database
        public static async Task InitializeDbForTestsAsync(DataContext context)
        {
            context.RoomPatient.RemoveRange(context.RoomPatient);
            context.Patient.RemoveRange(context.Patient);
            context.Room.RemoveRange(context.Room);

            // Arrange
            var Patient = new Patient
            {
                Name = "Test Patient",
                PhoneNumber = "1234567890",
                Age = 20,
                Gender = "Male"
            };
            context.Patient.Add(Patient);

            var ICURoom = new Room
            {
                RoomType = "ICU",
                MaxCapacity = 1,
                CurrentCapacity = 1
            };
            context.Room.Add(ICURoom);

            var GeneralRoom = new Room
            {
                RoomType = "General",
                MaxCapacity = 2,
                CurrentCapacity = 2
            };
            context.Room.Add(GeneralRoom);

            var PremiumRoom = new Room
            {
                RoomType = "Premium",
                MaxCapacity = 1,
                CurrentCapacity = 1
            };
            context.Room.Add(PremiumRoom);

            await context.SaveChangesAsync();
        }
    }
}

عند تشغيل dotnet test الآن، من الطبيعي أن تفشل بعض الاختبارات، لأن المتحكم الحالي يعيد Created حتى مع المدخلات غير الصالحة.

فشل اختبارات التحقق من البيانات قبل تطبيق قواعد التحقق في تي دي دي

الانتقال من الحالة الحمراء إلى الخضراء

الآن حان وقت إصلاح الفشل بإضافة قواعد التحقق الفعلية إلى نموذج Patient. حدّث الملف Patient.cs بالشكل التالي:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace TDD
{
    public class Patient : IValidatableObject
    {
        [Key]
        [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
        public int Id { get; set; }

        [Required]
        [StringLength(40, MinimumLength = 2, ErrorMessage = "The name should be between 2 & 40 characters.")]
        public String Name { get; set; }

        [Required]
        [DataType(DataType.PhoneNumber)]
        [RegularExpression(@"^(\d{7,12})$", ErrorMessage = "Not a valid phone number")]
        public String PhoneNumber { get; set; }

        [Required]
        [Range(1, 150)]
        public int Age { get; set; }

        [Required]
        public String Gender { get; set; }

        public Boolean IsAdmitted { get; set; }

        public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
        {
            // Only Male, Female or Other gender are allowed
            if (Gender.Equals("Male", System.StringComparison.CurrentCultureIgnoreCase) == false
                && Gender.Equals("Female", System.StringComparison.CurrentCultureIgnoreCase) == false
                && Gender.Equals("Other", System.StringComparison.CurrentCultureIgnoreCase) == false)
            {
                yield return new ValidationResult("The gender can either be Male, Female or Other");
            }
            yield return ValidationResult.Success;
        }
    }
}

أضفنا هنا سمات مثل [Required] و[StringLength] و[RegularExpression] و[Range]، كما استخدمنا الواجهة IValidatableObject لتنفيذ تحقق مخصص لحقل الجنس.

بعد تشغيل dotnet test مجدداً، يفترض أن ترى جميع اختبارات التحقق ناجحة.

نجاح اختبارات التحقق من نموذج المريض بعد تطبيق قواعد التحقق

اختبار منع تكرار المريض

الخطوة التالية في دورة TDD هي إضافة سلوك جديد عبر اختبار جديد. نريد الآن التأكد من أن النظام يمنع إدخال نفس المريض مرتين.

اكتب الاختبار أولاً

أضف الاختبار التالي داخل الفئة PatientTests:

[Fact]
public async Task PatientDuplicationTestsAsync()
{
    var scopeFactory = _factory.Services;
    using (var scope = scopeFactory.CreateScope())
    {
        var context = scope.ServiceProvider.GetService<DataContext>();
        await DBUtilities.InitializeDbForTestsAsync(context);

        // Arrange
        var Patient = await context.Patient.FirstOrDefaultAsync();
        var Request = new HttpRequestMessage(HttpMethod.Post, "api/patient");
        Request.Content = new StringContent(JsonSerializer.Serialize(Patient), Encoding.UTF8, "application/json");

        // Act
        var Response = await httpClient.SendAsync(Request);

        // Assert
        var StatusCode = Response.StatusCode;
        Assert.Equal(HttpStatusCode.BadRequest, StatusCode);
    }
}

استخدمنا هنا السمة [Fact] بدلاً من [Theory] لأننا نختبر سيناريو واحداً محدداً وليس مجموعة مدخلات مختلفة.

عند تشغيل الاختبارات ستفشل هذه الحالة، وهذا طبيعي لأن منطق منع التكرار لم يُكتب بعد.

تنفيذ المنطق داخل المتحكم

الآن عدّل ملف PatientController.cs ليصبح كالتالي:

using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace TDD.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class PatientController : Controller
    {
        private readonly DataContext _context;

        public PatientController(DataContext context)
        {
            _context = context;
        }

        [HttpPost]
        public async Task<IActionResult> AddPatientAsync([FromBody] Patient Patient)
        {
            var FetchedPatient = await _context.Patient.FirstOrDefaultAsync(x => x.PhoneNumber == Patient.PhoneNumber);

            // If the patient doesn't exist create a new one
            if (FetchedPatient == null)
            {
                _context.Patient.Add(Patient);
                await _context.SaveChangesAsync();
                return Created($"/patient/{Patient.Id}", Patient);
            }
            // Else throw a bad request
            else
            {
                return BadRequest();
            }
        }
    }
}

يعتمد هذا التنفيذ على البحث عن المريض بواسطة PhoneNumber باعتباره معرفاً شبه فريد في هذا المثال. إذا لم يُعثر على سجل مطابق تتم الإضافة، وإلا يُعاد الرد BadRequest.

بعد تشغيل dotnet test مرة أخرى، يجب أن تنجح اختبارات التحقق ومنع التكرار معاً.

ملاحظات مهمة عند توسيع المشروع

كلما أضفت نطاقات جديدة مثل Doctors أو Staff أو Instruments، يجب أن تتوسع استراتيجية الاختبار أيضاً.

  • أنشئ WAF مستقلاً لكل نطاق عند الحاجة.
  • استخدم أسماء مختلفة لقواعد بيانات InMemory حتى لا تتداخل البيانات بين الملفات.
  • أنشئ فئات مساعدة مستقلة لإعادة تهيئة البيانات الخاصة بكل مجال.
  • انتبه إلى أن الاختبارات في الملف الواحد لا تعمل بالتوازي عادةً، لكن الاختبارات في ملفات متعددة قد تعمل بالتوازي.
  • لا تنسَ أن قاعدة البيانات الحقيقية في المشروع الأساسي تحتاج إلى إعداد منفصل عن بيئة الاختبار.

كيف تكتب اختبارات جيدة فعلاً؟

الاختبار الجيد لا يقتصر على كونه ناجحاً، بل يجب أن يكون واضحاً وقابلاً للصيانة ويعكس متطلبات العمل الحقيقية.

  1. ابدأ بفهم المتطلبات بدقة قبل كتابة أي سطر.
  2. أنشئ هياكل أولية للفئات والواجهات والمتحكمات دون تعقيد.
  3. اكتب اختباراً يعبّر عن السلوك المطلوب.
  4. نفّذ أقل قدر من الكود لتمرير الاختبار.
  5. أعد الهيكلة Refactor مع الحفاظ على نجاح جميع الاختبارات.

كما يجدر التنبيه إلى أن هذا المثال لا يغطي المصادقة Authentication ولا التفويض Authorization، وهما عنصران أساسيان في التطبيقات الحقيقية، خصوصاً إذا كانت الواجهات البرمجية تتعامل مع بيانات حساسة مثل بيانات المرضى.

متى تكون منهجية TDD فعالة؟

تنجح هذه المنهجية عندما تكون المتطلبات مستقرة وواضحة. أما إذا كانت القواعد التجارية تتغير بشكل متكرر وغير منظم، فستصبح صيانة الاختبارات نفسها مكلفة. لذلك لا يمكن اعتبار TDD حلاً سحرياً، لكنه إطار عملي ممتاز لضبط الجودة وتقليل العيوب وتحسين ثقة الفريق في التغييرات المستقبلية.

ومن المهم أيضاً أن نفهم أن TDD لا يغني عن جميع أنواع الاختبار الأخرى. فهو يغطي غالباً اختبارات الوحدة والتكامل والوظائف، لكنك ستظل بحاجة إلى اختبارات القبول، واختبارات الإعدادات، والاختبارات النهائية قبل الإطلاق للإنتاج.

الخلاصة التقنية

إذا أردت تطبيق TDD بشكل عملي داخل .NET 5.0، فاختبارات التكامل تمنحك نقطة انطلاق قوية لأنها تختبر التطبيق بواقعية أعلى من اختبارات الوحدة المعزولة. استخدام xUnit مع WebApplicationFactory وEntityFrameworkCore.InMemory يوفّر بيئة فعالة لبناء اختبارات سريعة، قابلة للتكرار، وآمنة على قاعدة البيانات الأصلية. تقنياً، أفضل ما في هذا النهج أنه يدفعك إلى تصميم واجهات واضحة، وقواعد تحقق صريحة، وبنية مشروع أسهل في الصيانة والتوسع مستقبلاً.

اترك تعليقاً

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها بـ *