تو این سایت نوشته:
.#Colleague: I work with C
.Support: Oh, that dead language
من متوجه نمیشم! مگه ممکنه؟!
تو این سایت نوشته:
.#Colleague: I work with C
.Support: Oh, that dead language
من متوجه نمیشم! مگه ممکنه؟!
پیشنیاز
در این مطلب قصد داریم تست ServiceLayer را به جای تست درون حافظهای که با ابزارهای Mocking در قالب Unit Testing انجام میگیرد، به کمک یک دیتابیس واقعی سبک وزن در قالب Integration Testing انجام دهیم.
قدم اول
یک پروژه تست را ایجاد کنید؛ بهتر است برای نظم دهی به ساختار Solution، پروژههای تست را در پوشه ای به نام Tests نگهداری کنید.
قدم دوم
بستههای نیوگت زیر را نصب کنید:
PM> install-package NUnit PM> install-package Shouldly PM> install-package EntityFramework PM> install-package FakeHttpContext
قدم سوم
نسخه دیتابیس انتخابی برای تست خودکار، LocalDB می باشد. لازم است در ابتدای اجرای تستها دیتابیس مربوط به Integration Test ایجاد شده و بعد از اتمام نیز دیتابیس مورد نظر حذف شود؛ برای این منظور از کلاس TestSetup استفاده خواهیم کرد.
[SetUpFixture] public class TestSetup { [OneTimeSetUp] public void SetUpDatabase() { DestroyDatabase(); CreateDatabase(); } [OneTimeTearDown] public void TearDownDatabase() { DestroyDatabase(); } //... }
با توجه به اینکه کلاس TestSetup با [SetUpFixture] تزئین شده است، Nunit قبل از اجرای تستها سراغ این کلاس آمده و متد SetUpDatebase را به دلیل تزئین شدن با [OneTimeSetUp]، قبل از اجرای تستها و متد TearDownDatabase را بدلیل تزئین شدن با [OneTimeTearDown] بعد از اجرای تمام تستها، اجرا خواهد کرد.
متد CreateDatabase
private static void CreateDatabase() { ExecuteSqlCommand(Master, string.Format(SqlResource.DatabaseScript, FileName)); //Use T-Sql Scripts For Create Database //ExecuteSqlCommand(MyAppTest, SqlResources.V1_0_0); var migration = new MigrateDatabaseToLatestVersion<ApplicationDbContext, DataLayer.Migrations.Configuration>(); migration.InitializeDatabase(new ApplicationDbContext()); } private static SqlConnectionStringBuilder Master => new SqlConnectionStringBuilder { DataSource = @"(LocalDB)\MSSQLLocalDB", InitialCatalog = "master", IntegratedSecurity = true }; private static string FileName => Path.Combine( Path.GetDirectoryName( Assembly.GetExecutingAssembly().Location), "MyAppTest.mdf");
برای مدیریت محل ذخیره سازی فایلهای دیتابیس، ابتدا دستورات ایجاد «دیتابیس تست» را برروی دیتابیس master اجرا میکنیم و در ادامه برای ساخت جداول از مکانیزم Migration خود EF استفاده شده است.
لازم است رشته اتصال به این دیتابیس ایجاد شده را در فایل App.config پروژه تست قرار دهید:
<connectionStrings> <add name="DefaultConnection" providerName="System.Data.SqlClient" connectionString="Data Source=(LocalDB)\MSSQLLocalDb;Initial Catalog=MyAppTest;Integrated Security=True;" /> </connectionStrings>
متد DestroyDatabase
private static void DestroyDatabase() { var fileNames = ExecuteSqlQuery(Master, SqlResource.SelecDatabaseFileNames, row => (string)row["physical_name"]); if (!fileNames.Any()) return; ExecuteSqlCommand(Master, SqlResource.DetachDatabase); fileNames.ForEach(File.Delete); }
در این متد ابتدا آدرس فایلهای مرتبط با «دیتابیس تست» واکشی شده و در ادامه دستورات Detach دیتابیس انجام شده و فایلهای مرتبط حذف خواهند شد. فایلهای دیتابیس در مسیری شبیه به آدرس نشان داده شدهی در شکل زیر ذخیره خواهند شد.
قدم چهارم
برای جلوگیری از تداخل بین تستها لازم است تک تک تستها از هم ایزوله باشند؛ یکی از راه حلهای موجود، استفاده از تراکنشها میباشد. برای این منظور امکان AutoRollback را به صورت خودکار به متدهای تست با استفاده از Attribute زیر اعمال خواهیم کرد:
public class AutoRollbackAttribute : Attribute, ITestAction { private TransactionScope _scope; public void BeforeTest(ITest test) { _scope = new TransactionScope(TransactionScopeOption.RequiresNew,new TransactionOptions {IsolationLevel = IsolationLevel.Snapshot}); } public void AfterTest(ITest test) { _scope?.Dispose(); _scope = null; } public ActionTargets Targets => ActionTargets.Test; }
متدهای BeforTest و AfterTest به ترتیب قبل و بعد از اجرای متدهای تست تزئین شده با این Attribute اجرا خواهند شد.
در مواقعی هم که به HttpConext نیاز دارید، میتوانید از کتابخانه FakeHttpContext بهره ببرید. برای این مورد هم میتوان Attributeای را به مانند AutoRollback در نظر گرفت.
public class HttpContextAttribute:Attribute,ITestAction { private FakeHttpContext.FakeHttpContext _httpContext; public void BeforeTest(ITest test) { _httpContext = new FakeHttpContext.FakeHttpContext(); } public void AfterTest(ITest test) { _httpContext?.Dispose(); _httpContext = null; } public ActionTargets Targets => ActionTargets.Test; }
کاری که FakeHttpContext انجام میدهد، مقدار دهی HttpContext.Current با یک پیاده سازی ساختگی میباشد.
قدم پنجم
به عنوان مثال اگر بخواهیم برای سرویس «گروه کاربری»، Integration Test بنویسیم، به شکل زیر عمل خواهیم کرد:
namespace MyApp.IntegrationTests.ServiceLayer { [TestFixture] [AutoRollback] [HttpContext] public class RoleServiceTests { private IRoleApplicationService _roleService; [SetUp] public void Init() { } [TearDown] public void Clean() { } [OneTimeSetUp] public void SetUp() { _roleService = IoC.Resolve<IRoleApplicationService>(); using (var uow = IoC.Resolve<IUnitOfWork>()) { RoleInitialDataBuilder.Build(uow); } } [OneTimeTearDown] public void TearDown() { } [Test] [TestCase("Role1")] public void Should_Create_New_Role(string role) { var viewModel = new RoleCreateViewModel { Name = role }; _roleService.Create(viewModel); using (var context = IoC.Resolve<IUnitOfWork>()) { var user = context.Set<Role>().FirstOrDefault(a => a.Name == role); user.ShouldNotBeNull(); } } [Test] public void Should_Not_Create_New_Role_With_Admin_Name() { var viewModel = new RoleCreateViewModel { Name = "Admin" }; Assert.Throws<DbUpdateException>(() => _roleService.Create(viewModel)); } [Test] public void Should_AdminRole_Exists() { using (var context = IoC.Resolve<IUnitOfWork>()) { var user = context.Set<Role>().FirstOrDefault(a => a.Name == "Admin"); user.ShouldNotBeNull(); } } [Test] public void Should_Not_Create_New_Role_Without_Name() { Assert.Throws<ValidationException>(() => _roleService.Create(new RoleCreateViewModel { Name = null })); } } }
با این خروجی:
using System; using System.Linq; using System.Reflection; // Add a ref. to \Program Files\Microsoft ASP.NET\ASP.NET MVC 4\Assemblies\System.Web.Mvc.dll using System.Web.Mvc; // Add a ref. to System.Web using System.Web.UI; namespace FindOutputCaches { class Program { static void Main(string[] args) { var path = @"D:\path\bin\site.dll"; var asmTarget = Assembly.LoadFrom(path); checkCsrfTokens(asmTarget); Console.WriteLine("Press a key..."); Console.Read(); } private static void checkCsrfTokens(Assembly asmTarget) { // یافتن کلیه کنترلرها var controllers = asmTarget.GetTypes() .Where(type => typeof(IController).IsAssignableFrom(type) && !type.Name.StartsWith("T4MVC")) .ToList(); foreach (var controller in controllers) { // یافتن کلیه اکشن متدهای کنترلر جاری var actionMethods = controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) .Where(method => typeof(ActionResult).IsAssignableFrom(method.ReturnType)) .ToList(); foreach (var method in actionMethods) { var httpPostAttributes = method.GetCustomAttributes(typeof(HttpPostAttribute), true); if (httpPostAttributes == null || !httpPostAttributes.Any()) continue; var csrfTokens = method.GetCustomAttributes(typeof(ValidateAntiForgeryTokenAttribute), true); if (csrfTokens == null || !csrfTokens.Any()) { Console.WriteLine("Detected [HttpPost] without [ValidateAntiForgeryToken] in:\n {0}-->{1}", controller.FullName, method.Name); } } } } } }
public class Account
{
public virtual int Id { get; set; }
[NotNullNotEmpty]
[Length(Min = 3, Max = 120, Message = "طول نام باید بین 3 و 120 کاراکتر باشد")]
public virtual string Name { get; set; }
}
public class Category
{
public virtual int Id { get; set; }
[NotNullNotEmpty]
[Length(Min = 3, Max = 130, Message = "طول نام باید بین 3 و 130 کاراکتر باشد")]
public virtual string Name { get; set; }
}
public class Payee
{
public virtual int Id { get; set; }
[NotNullNotEmpty]
[Length(Min = 3, Max = 120, Message = "طول نام باید بین 3 و 120 کاراکتر باشد")]
public virtual string Name { get; set; }
}
public class Bill
{
public virtual int Id { get; set; }
[NotNull]
public virtual Account Account { get; set; }
[NotNull]
public virtual Category Category { get; set; }
[NotNull]
public virtual Payee Payee { get; set; }
[NotNull]
public virtual decimal Amount { set; get; }
[NotNull]
public virtual DateTime BillDate { set; get; }
[NotNullNotEmpty]
[Length(Min = 1, Max = 500, Message = "طول توضیحات باید بین 1 و 500 کاراکتر باشد")]
public virtual string Description { get; set; }
}
create table Accounts (
AccountId INT IDENTITY NOT NULL,
Name NVARCHAR(120) not null,
primary key (AccountId)
)
create table Bills (
BillId INT IDENTITY NOT NULL,
Amount DECIMAL(19,5) not null,
BillDate DATETIME not null,
Description NVARCHAR(500) not null,
AccountId INT not null,
CategoryId INT not null,
PayeeId INT not null,
primary key (BillId)
)
create table Categories (
CategoryId INT IDENTITY NOT NULL,
Name NVARCHAR(130) not null,
primary key (CategoryId)
)
create table Payees (
PayeeId INT IDENTITY NOT NULL,
Name NVARCHAR(120) not null,
primary key (PayeeId)
)
alter table Bills
add constraint fk_Account_Bill
foreign key (AccountId)
references Accounts
alter table Bills
add constraint fk_Category_Bill
foreign key (CategoryId)
references Categories
alter table Bills
add constraint fk_Payee_Bill
foreign key (PayeeId)
references Payees
var newBill = new Bill
{
Account = accountRepository.GetByKey(1),
Amount = 1,
BillDate = DateTime.Now,
Category = categoryRepository.GetByKey(1),
Description = "testestest...",
Payee = payeeRepository.GetByKey(1)
};
SELECT account0_.AccountId as AccountId0_0_, account0_.Name as Name0_0_
FROM Accounts account0_ WHERE account0_.AccountId=@p0;@p0 = 1 [Type: Int32 (0)]
SELECT category0_.CategoryId as CategoryId2_0_, category0_.Name as Name2_0_
FROM Categories category0_ WHERE category0_.CategoryId=@p0;@p0 = 1 [Type: Int32 (0)]
SELECT payee0_.PayeeId as PayeeId3_0_, payee0_.Name as Name3_0_
FROM Payees payee0_ WHERE payee0_.PayeeId=@p0;@p0 = 1 [Type: Int32 (0)]
INSERT INTO Bills (Amount, BillDate, Description, AccountId, CategoryId, PayeeId)
VALUES (@p0, @p1, @p2, @p3, @p4, @p5);
select SCOPE_IDENTITY();
@p0 = 1 [Type: Decimal (0)],
@p1 = 2010/12/27 11:48:33 ق.ظ [Type: DateTime (0)],
@p2 = 'testestest...' [Type: String (500)],
@p3 = 1 [Type: Int32 (0)],
@p4 = 1 [Type: Int32 (0)],
@p5 = 1 [Type: Int32 (0)]
INSERT INTO Bills (Amount, BillDate, Description, AccountId, CategoryId, PayeeId)
VALUES (@p0, @p1, @p2, @p3, @p4, @p5);
select SCOPE_IDENTITY();
@p0 = 1 [Type: Decimal (0)],
@p1 = 2010/12/27 11:58:22 ق.ظ [Type: DateTime (0)],
@p2 = 'testestest...' [Type: String (500)],
@p3 = 1 [Type: Int32 (0)],
@p4 = 1 [Type: Int32 (0)],
@p5 = 1 [Type: Int32 (0)]
public partial class Bill : EntityObject
{
public global::System.Int32 BillId {set;get;}
public global::System.Decimal Amount {set;get;}
public global::System.DateTime BillDate {set;get;}
public global::System.String Description {set;get;}
public global::System.Int32 AccountId {set;get;}
public global::System.Int32 CategoryId {set;get;}
public global::System.Int32 PayeeId {set;get;}
public Account Account {set;get;}
public Category Category {set;get;}
}
using(var client = new HttpClient()) { // do something with http client }
Unable to connect to the remote server System.Net.Sockets.SocketException: Only one usage of each socket address (protocol/network address/port) is normally permitted.
using (var client = new HttpClient()) { var result = await client.GetAsync("http://example.com/"); }
for (int i = 0; i < 10; i++) { using (var client = new HttpClient()) { var result = await client.GetAsync("http://example.com/"); Console.WriteLine(result.StatusCode); } }
TCP 192.168.1.6:13996 93.184.216.34:http TIME_WAIT TCP 192.168.1.6:13997 93.184.216.34:http TIME_WAIT TCP 192.168.1.6:13998 93.184.216.34:http TIME_WAIT TCP 192.168.1.6:13999 93.184.216.34:http TIME_WAIT TCP 192.168.1.6:14000 93.184.216.34:http TIME_WAIT TCP 192.168.1.6:14001 93.184.216.34:http TIME_WAIT TCP 192.168.1.6:14002 93.184.216.34:http TIME_WAIT TCP 192.168.1.6:14003 93.184.216.34:http TIME_WAIT TCP 192.168.1.6:14004 93.184.216.34:http TIME_WAIT TCP 192.168.1.6:14005 93.184.216.34:http TIME_WAIT
[HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\TcpTimedWaitDelay]
CancelPendingRequests DeleteAsync GetAsync GetByteArrayAsync GetStreamAsync GetStringAsync PostAsync PutAsync SendAsync
BaseAddress DefaultRequestHeaders MaxResponseContentBufferSize Timeout
var sp = ServicePointManager.FindServicePoint(new Uri("http://thisisasample.com")); sp.ConnectionLeaseTimeout = 60*1000; //In milliseconds
using System; using System.Collections.Generic; using System.Net.Http; namespace HttpClientTips { public interface IHttpClientFactory : IDisposable { HttpClient GetOrCreate( Uri baseAddress, IDictionary<string, string> defaultRequestHeaders = null, TimeSpan? timeout = null, long? maxResponseContentBufferSize = null, HttpMessageHandler handler = null); } }
using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Threading; namespace HttpClientTips { /// <summary> /// Lifetime of this class should be set to `Singleton`. /// </summary> public class HttpClientFactory : IHttpClientFactory { // 'GetOrAdd' call on the dictionary is not thread safe and we might end up creating the HttpClient more than // once. To prevent this Lazy<> is used. In the worst case multiple Lazy<> objects are created for multiple // threads but only one of the objects succeeds in creating the HttpClient. private readonly ConcurrentDictionary<Uri, Lazy<HttpClient>> _httpClients = new ConcurrentDictionary<Uri, Lazy<HttpClient>>(); private const int ConnectionLeaseTimeout = 60 * 1000; // 1 minute public HttpClientFactory() { // Default is 2 minutes: https://msdn.microsoft.com/en-us/library/system.net.servicepointmanager.dnsrefreshtimeout(v=vs.110).aspx ServicePointManager.DnsRefreshTimeout = (int)TimeSpan.FromMinutes(1).TotalMilliseconds; // Increases the concurrent outbound connections ServicePointManager.DefaultConnectionLimit = 1024; } public HttpClient GetOrCreate( Uri baseAddress, IDictionary<string, string> defaultRequestHeaders = null, TimeSpan? timeout = null, long? maxResponseContentBufferSize = null, HttpMessageHandler handler = null) { return _httpClients.GetOrAdd(baseAddress, uri => new Lazy<HttpClient>(() => { // Reusing a single HttpClient instance across a multi-threaded application means // you can't change the values of the stateful properties (which are not thread safe), // like BaseAddress, DefaultRequestHeaders, MaxResponseContentBufferSize and Timeout. // So you can only use them if they are constant across your application and need their own instance if being varied. var client = handler == null ? new HttpClient { BaseAddress = baseAddress } : new HttpClient(handler, disposeHandler: false) { BaseAddress = baseAddress }; setRequestTimeout(timeout, client); setMaxResponseBufferSize(maxResponseContentBufferSize, client); setDefaultHeaders(defaultRequestHeaders, client); setConnectionLeaseTimeout(baseAddress, client); return client; }, LazyThreadSafetyMode.ExecutionAndPublication)).Value; } public void Dispose() { foreach (var httpClient in _httpClients.Values) { httpClient.Value.Dispose(); } } private static void setConnectionLeaseTimeout(Uri baseAddress, HttpClient client) { // This ensures connections are used efficiently but not indefinitely. client.DefaultRequestHeaders.ConnectionClose = false; // keeps the connection open -> more efficient use of the client ServicePointManager.FindServicePoint(baseAddress).ConnectionLeaseTimeout = ConnectionLeaseTimeout; // ensures connections are not used indefinitely. } private static void setDefaultHeaders(IDictionary<string, string> defaultRequestHeaders, HttpClient client) { if (defaultRequestHeaders == null) { return; } foreach (var item in defaultRequestHeaders) { client.DefaultRequestHeaders.Add(item.Key, item.Value); } } private static void setMaxResponseBufferSize(long? maxResponseContentBufferSize, HttpClient client) { if (maxResponseContentBufferSize.HasValue) { client.MaxResponseContentBufferSize = maxResponseContentBufferSize.Value; } } private static void setRequestTimeout(TimeSpan? timeout, HttpClient client) { if (timeout.HasValue) { client.Timeout = timeout.Value; } } } }
namespace HttpClientTips.Web { public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddSingleton<IHttpClientFactory, HttpClientFactory>(); services.AddMvc(); }
namespace HttpClientTips.Web.Controllers { public class HomeController : Controller { private readonly IHttpClientFactory _httpClientFactory; public HomeController(IHttpClientFactory httpClientFactory) { _httpClientFactory = httpClientFactory; } public async Task<IActionResult> Index() { var host = new Uri("http://localhost:5000"); var httpClient = _httpClientFactory.GetOrCreate(host); var responseMessage = await httpClient.GetAsync("home/about").ConfigureAwait(false); var responseContent = await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false); return Content(responseContent); }
[MyAttr(nameof(parameter))] void Method(string parameter) { }
[MyAttr(nameof(T))] void Method<T>() { }
void Method([MyAttr(nameof(parameter))] int parameter) { }
[return: NotNullIfNotNull(nameof(path))] public static string? GetUrl(string? path) => !string.IsNullOrEmpty(path) ? $"https://localhost/api/{path}" : null;
let name: string; name = "Vahid"; // OK name = null; // OK name = undefined; // OK
let age: number; age = 24; // OK age = null; // OK age = undefined; // OK
{ "compilerOptions": { "strictNullChecks": true } }
let foo: string = null; // Error! Type 'null' is not assignable to type 'string'.
Uncaught ReferenceError: foo is not defined Uncaught TypeError: window.foo is not a function
// With strictNullChecks set to false let d: Array<number> = [null, undefined, 10, 15]; //OK let e: Array<string> = ["pie", null, ""]; //OK // With strictNullChecks set to true let d: Array<number> = [null, undefined, 10, 15]; // Error let e: Array<string> = ["pie", null, ""]; // Error
{ "compilerOptions": { "strict": true /* Enable all strict type-checking options. */ } }
{ "compilerOptions": { "strict": true, "noImplicitThis": false } }
let foo: string | null = null; // Okay!
let name: string | null; name = "Vahid"; // OK name = null; // OK name = undefined; // Error
let z = null;
testAssignedBeforeUseChecking() { let x: number; console.log(x); }
[ts] Variable 'x' is used before being assigned.
let z: number | undefined; console.log(z);
method1(x?: number) { }
method1(x?: number | undefined) { }
f(x: number): string { return x.toString(); } testTypeGuards() { let x: number | null | undefined; if (x) { this.f(x); // Ok, type of x is number here } else { this.f(x); // Error, type of x is number? here } }
Argument of type 'number | null | undefined' is not assignable to parameter of type 'number'. Type 'undefined' is not assignable to type 'number'.
getLength(s: string | null) { return s ? s.length : 0; }
isNumber(n: any): n is number { // type guard return typeof n === "number"; }
usedMb(usedBytes?: number): number | undefined { return this.isNumber(usedBytes) ? (usedBytes / (1024 * 1024)) : undefined; }
usedMb2(usedBytes?: number): number | undefined { return usedBytes ? (usedBytes / (1024 * 1024)) : undefined; }
formatUsedMb(): string { //ERROR: TS2531: Object is possibly undefined return this.usedMb(123).toFixed(0).toString(); }
formatUsed(): string { const usedMb = this.usedMb(123); return usedMb ? usedMb.toFixed(0).toString() : ""; }
export interface User { name: string; age?: number; }
printUserInfo(user: User) { if (user.age != null) { console.log(`${user.name}, ${user.age.toString()}`); } }
printUserInfo(user: User) { console.log(`${user.name}, ${user.age.toString()}`); }
printUserInfo(user: User) { console.log(`${user.name}, ${user.age!.toString()}`); }
[tslint] Forbidden non null assertion (no-non-null-assertion)