今天来到了最后的压轴章节:单元测试

我们已经有了完整的程序结构,现在是时候来对我们的组件做单元测试了。
在UnitTestingWebAPI.Tests类库上添加UnitTestingWebAPI.Domain, UnitTestingWebAPI.Data, UnitTestingWebAPI.Service和UnitTestingWebAPI.API.Core 同样要安装下列的Nuget 包:
Entity Framework
Microsoft.AspNet.WebApi.Core
Microsoft.AspNet.WebApi.Client
Microsoft.AspNet.WebApi.Owin
Microsoft.AspNet.WebApi.SelfHost
Micoroft.Owin
Owin
Micoroft.Owin.Hosting
Micoroft.Owin.Host.HttpListener
Autofac.WebApi2
NUnit
NUnitTestAdapter
从清单中可知,我们将用NUnit 来写单元测试
Services 单元测试
写单元测试的第一件事是需要去设置或初始化一些单元测试中要用到的变量,NUnit框架则给要测试的方法添加Setup特性,在任何其他的NUnit测试开始之前,这一方法会先执行,把Services层注入到Controller的构造函数之后的第一件事就是进行单元测试。因此在对WebAPI进行单元测试之前需要仿造Repositories和Service。
在这个例子中会看到如何仿造ArticleService, 并在这个Service的构造函数中注入IArticleRepository和IUnitOfWork,所以我们需要创建两个"特别的"实例来注入。
ArticleService Constructor
private readonly IArticleRepository articlesRepository;
private readonly IUnitOfWork unitOfWork;
public ArticleService(IArticleRepository articlesRepository, IUnitOfWork unitOfWork)
{
this.articlesRepository = articlesRepository;
this.unitOfWork = unitOfWork;
}这里的"特别的",是因为这些实例不是真正访问数据库的实例.
注意
单元测试必须运行在内存中并且不应该访问数据库. 所有核心的方法必须通过像我们的例子中用Mock这样的框架仿造。这个方式自动的测试会更快些。单元测试最基本的目的是更多的测试组件的行为,而不是真正的结果.
开始测试ArticleService,创建一个ServiceTests的文件并添加下列代码:
[TestFixture]
public class ServicesTests
{
#region Variables
IArticleService _articleService;
IArticleRepository _articleRepository;
IUnitOfWork _unitOfWork;
List _randomArticles;
#endregion
#region Setup
[SetUp]
public void Setup()
{
_randomArticles = SetupArticles();
_articleRepository = SetupArticleRepository();
_unitOfWork = new Mock().Object;
_articleService = new ArticleService(_articleRepository, _unitOfWork);
}
public List SetupArticles()
{
int _counter = new int();
List _articles = BloggerInitializer.GetAllArticles();
foreach (Article _article in _articles)
_article.ID = ++_counter;
return _articles;
}
public IArticleRepository SetupArticleRepository()
{
// Init repository
var repo = new Mock();
// Setup mocking behavior
repo.Setup(r => r.GetAll()).Returns(_randomArticles);
repo.Setup(r => r.GetById(It.IsAny()))
.Returns(new Func(
id => _randomArticles.Find(a => a.ID.Equals(id))));
repo.Setup(r => r.Add(It.IsAny()))
.Callback(new Action(newArticle =>
{
dynamic maxArticleID = _randomArticles.Last().ID;
newArticle.ID = maxArticleID + 1;
newArticle.DateCreated = DateTime.Now;
_randomArticles.Add(newArticle);
}));
repo.Setup(r => r.Update(It.IsAny()))
.Callback(new Action(x =>
{
var oldArticle = _randomArticles.Find(a => a.ID == x.ID);
oldArticle.DateEdited = DateTime.Now;
oldArticle = x;
}));
repo.Setup(r => r.Delete(It.IsAny()))
.Callback(new Action(x =>
{
var _articleToRemove = _randomArticles.Find(a => a.ID == x.ID);
if (_articleToRemove != null)
_randomArticles.Remove(_articleToRemove);
}));
// Return mock implementation
return repo.Object;
}
#endregion
} 如果你直接copy代码可能会报错:
One or more types required to compile a dynaic expression ....
解决办法:
在Assembiles中添加Microsoft.CSharp.dll

在SetupArticleRepository()方法中我们模仿了_articleRepository的行为,换句话说,当一个特定的方法使用了这个Reporistory的实例,就会得到我们所期待的结果。然后我们在_articleService的构造函数中注入这个实例。我们用下面代码测试_articleService.GetArticles()的行为是否是我们所期待的.
ServiceShouldReturnAllArticles Test
[Test]
public void ServiceShouldReturnAllArticles()
{
var articles = _articleService.GetArticles();
NUnit.Framework.Assert.That(articles, Is.EqualTo(_randomArticles));
}编译项目,运行测试,要确保这个测试变为绿色通过状态,用同样的方式创建下面的测试:
Services Test
[Test]
public void ServiceShouldReturnRightArticle()
{
var wcfSecurityArticle = _articleService.GetArticle(2);
NUnit.Framework.Assert.That(wcfSecurityArticle,
Is.EqualTo(_randomArticles.Find(a => a.Title.Contains("Secure WCF Services"))));
}
[Test]
public void ServiceShouldAddNewArticle()
{
var _newArticle = new Article()
{
Author = "Chris Sakellarios",
Contents = "If you are an ASP.NET MVC developer, you will certainly..",
Title = "URL Rooting in ASP.NET (Web Forms)",
URL = "https://chsakell.com/2013/12/15/url-rooting-in-asp-net-web-forms/"
};
int _maxArticleIDBeforeAdd = _randomArticles.Max(a => a.ID);
_articleService.CreateArticle(_newArticle);
NUnit.Framework.Assert.That(_newArticle, Is.EqualTo(_randomArticles.Last()));
NUnit.Framework.Assert.That(_maxArticleIDBeforeAdd + 1, Is.EqualTo(_randomArticles.Last().ID));
}
[Test]
public void ServiceShouldUpdateArticle()
{
var _firstArticle = _randomArticles.First();
_firstArticle.Title = "OData feat. ASP.NET Web API"; // reversed
_firstArticle.URL = "http://t.co/fuIbNoc7Zh"; // short link
_articleService.UpdateArticle(_firstArticle);
NUnit.Framework.Assert.That(_firstArticle.DateEdited, Is.Not.EqualTo(DateTime.MinValue));
NUnit.Framework.Assert.That(_firstArticle.URL, Is.EqualTo("http://t.co/fuIbNoc7Zh"));
NUnit.Framework.Assert.That(_firstArticle.ID, Is.EqualTo(1)); // hasn't changed
}
[Test]
public void ServiceShouldDeleteArticle()
{
int maxID = _randomArticles.Max(a => a.ID); // Before removal
var _lastArticle = _randomArticles.Last();
// Remove last article
_articleService.DeleteArticle(_lastArticle);
NUnit.Framework.Assert.That(maxID, Is.GreaterThan(_randomArticles.Max(a => a.ID))); // Max reduced by 1
}WebAPI 控制器单元测试
在熟悉了伪造Services行为测试的基础上,来进行WebAPI控制器的单元测试。
第一件事:设置在测试中需要的变量。
用下面的代码创建用于测试的控制器:
[TestFixture]
public class ControllerTests
{
#region Variables
IArticleService _articleService;
IArticleRepository _articleRepository;
IUnitOfWork _unitOfWork;
List _randomArticles;
#endregion
#region Setup
[SetUp]
public void Setup()
{
_randomArticles = SetupArticles();
_articleRepository = SetupArticleRepository();
_unitOfWork = new Mock().Object;
_articleService = new ArticleService(_articleRepository, _unitOfWork);
}
///
/// Setup Articles
///
///
public List SetupArticles()
{
int _counter = new int();
List _articles = BloggerInitializer.GetAllArticles();
foreach (Article _article in _articles)
_article.ID = ++_counter;
return _articles;
}
///
/// Emulate _articleRepository behavior
///
///
public IArticleRepository SetupArticleRepository()
{
// Init repository
var repo = new Mock();
// Get all articles
repo.Setup(r => r.GetAll()).Returns(_randomArticles);
// Get Article by id
repo.Setup(r => r.GetById(It.IsAny()))
.Returns(new Func(
id => _randomArticles.Find(a => a.ID.Equals(id))));
// Add Article
repo.Setup(r => r.Add(It.IsAny()))
.Callback(new Action(newArticle =>
{
dynamic maxArticleID = _randomArticles.Last().ID;
newArticle.ID = maxArticleID + 1;
newArticle.DateCreated = DateTime.Now;
_randomArticles.Add(newArticle);
}));
// Update Article
repo.Setup(r => r.Update(It.IsAny()))
.Callback(new Action(x =>
{
var oldArticle = _randomArticles.Find(a => a.ID == x.ID);
oldArticle.DateEdited = DateTime.Now;
oldArticle.URL = x.URL;
oldArticle.Title = x.Title;
oldArticle.Contents = x.Contents;
oldArticle.BlogID = x.BlogID;
}));
// Delete Article
repo.Setup(r => r.Delete(It.IsAny()))
.Callback(new Action(x =>
{
var _articleToRemove = _randomArticles.Find(a => a.ID == x.ID);
if (_articleToRemove != null)
_randomArticles.Remove(_articleToRemove);
}));
// Return mock implementation
return repo.Object;
}
#endregion
} 控制器的类和其它的类一样,所以我们可以分开各自测试。下面测试_articlesController.GetArticles(),看看是否能返回所有的文章。
[Test]
public void ControlerShouldReturnAllArticles()
{
var _articlesController = new ArticlesController(_articleService);
var result = _articlesController.GetArticles();
CollectionAssert.AreEqual(result, _randomArticles);
}请确保测试已绿色通过,我们初始化了3条数据,用_articlesController.GetArticle(3)测试看看能否返回最后一条。
[Test]
public void ControlerShouldReturnLastArticle()
{
var _articlesController = new ArticlesController(_articleService);
var result = _articlesController.GetArticle(3) as OkNegotiatedContentResult;
Assert.IsNotNull(result);
Assert.AreEqual(result.Content.Title, _randomArticles.Last().Title);
} 测试一个无效的Update操作,必须失败并且返回一个BadRequestResult, 重新调用设置在_articleRepository上的Update操作。
repo.Setup(r => r.Update(It.IsAny())) .Callback(new Action (x => { var oldArticle = _randomArticles.Find(a => a.ID == x.ID); oldArticle.DateEdited = DateTime.Now; oldArticle.URL = x.URL; oldArticle.Title = x.Title; oldArticle.Contents = x.Contents; oldArticle.BlogID = x.BlogID; }));
所以,当我们测试一个不存在的文章就应该返回失败信息。
[Test]
public void ControlerShouldPutReturnBadRequestResult()
{
var _articlesController = new ArticlesController(_articleService)
{
Configuration = new HttpConfiguration(),
Request = new HttpRequestMessage
{
Method = HttpMethod.Put,
RequestUri = new Uri("http://localhost/api/articles/-1")
}
};
var badresult = _articlesController.PutArticle(-1, new Article() { Title = "Unknown Article" });
Assert.That(badresult, Is.TypeOf());
} 通过分别成功更新第一篇文章、发表一篇新文章、发布失败一篇文章来完成我们的单元测试。
Controller 单元测试
[Test]
public void ControlerShouldPutUpdateFirstArticle()
{
var _articlesController = new ArticlesController(_articleService)
{
Configuration = new HttpConfiguration(),
Request = new HttpRequestMessage
{
Method = HttpMethod.Put,
RequestUri = new Uri("http://localhost/api/articles/1")
}
};
IHttpActionResult updateResult = _articlesController.PutArticle(1, new Article()
{
ID = 1,
Title = "ASP.NET Web API feat. OData",
URL = "http://t.co/fuIbNoc7Zh",
Contents = @"OData is an open standard protocol.."
}) as IHttpActionResult;
Assert.That(updateResult, Is.TypeOf());
StatusCodeResult statusCodeResult = updateResult as StatusCodeResult;
Assert.That(statusCodeResult.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
Assert.That(_randomArticles.First().URL, Is.EqualTo("http://t.co/fuIbNoc7Zh"));
}
[Test]
public void ControlerShouldPostNewArticle()
{
var article = new Article
{
Title = "Web API Unit Testing",
URL = "https://chsakell.com/web-api-unit-testing",
Author = "Chris Sakellarios",
DateCreated = DateTime.Now,
Contents = "Unit testing Web API.."
};
var _articlesController = new ArticlesController(_articleService)
{
Configuration = new HttpConfiguration(),
Request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("http://localhost/api/articles")
}
};
_articlesController.Configuration.MapHttpAttributeRoutes();
_articlesController.Configuration.EnsureInitialized();
_articlesController.RequestContext.RouteData = new HttpRouteData(
new HttpRoute(), new HttpRouteValueDictionary { { "_articlesController", "Articles" } });
var result = _articlesController.PostArticle(article) as CreatedAtRouteNegotiatedContentResult;
Assert.That(result.RouteName, Is.EqualTo("DefaultApi"));
Assert.That(result.Content.ID, Is.EqualTo(result.RouteValues["id"]));
Assert.That(result.Content.ID, Is.EqualTo(_randomArticles.Max(a => a.ID)));
}
[Test]
public void ControlerShouldNotPostNewArticle()
{
var article = new Article
{
Title = "Web API Unit Testing",
URL = "https://chsakell.com/web-api-unit-testing",
Author = "Chris Sakellarios",
DateCreated = DateTime.Now,
Contents = null
};
var _articlesController = new ArticlesController(_articleService)
{
Configuration = new HttpConfiguration(),
Request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri("http://localhost/api/articles")
}
};
_articlesController.Configuration.MapHttpAttributeRoutes();
_articlesController.Configuration.EnsureInitialized();
_articlesController.RequestContext.RouteData = new HttpRouteData(
new HttpRoute(), new HttpRouteValueDictionary { { "Controller", "Articles" } });
_articlesController.ModelState.AddModelError("Contents", "Contents is required field");
var result = _articlesController.PostArticle(article) as InvalidModelStateResult;
Assert.That(result.ModelState.Count, Is.EqualTo(1));
Assert.That(result.ModelState.IsValid, Is.EqualTo(false));
} 上面测试的重点,我们请求的几个方面:返回码或路由属性。
管理 Handler单元测试
你可以通过创建HttpMessageInvoker的实例来测试Message Handler, 解析你要测试的Handler实例并调用SendAsync 方法。创建一个MessageHandlerTest.cs文件,并贴上下面的启动设置代码
#region Variables
private EndRequestHandler _endRequestHandler;
private HeaderAppenderHandler _headerAppenderHandler;
#endregion
#region Setup
[SetUp]
public void Setup()
{
// Direct MessageHandler test
_endRequestHandler = new EndRequestHandler();
_headerAppenderHandler = new HeaderAppenderHandler()
{
InnerHandler = _endRequestHandler
};
}
#endregion我们在HeaderAppenderHandler的内部设置另外一个可以终止请求的Hanlder.只要Uri中包含一个测试字符,从新调用EndRequestHandler将会终止请求.现在来测试.
[Test]
public async void ShouldAppendCustomHeader()
{
var invoker = new HttpMessageInvoker(_headerAppenderHandler);
var result = await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get,
new Uri("http://localhost/api/test/")), CancellationToken.None);
Assert.That(result.Headers.Contains("X-WebAPI-Header"), Is.True);
Assert.That(result.Content.ReadAsStringAsync().Result,
Is.EqualTo("Unit testing message handlers!"));
}假如要做一个集成测试:当一个请求被消息管道分配到Controller的Action的真实behavior。
这将需要运行WebApi,然后运行单元测试。怎么做呢?必须是 通过Self host的模式运行API,然后设置恰当的配置。
在UnitTestingWebAPI.Tests的项目中添加Startup.cs文件:
Hosting/Startup.cs
public class Startup
{
public void Configuration(IAppBuilder appBuilder)
{
var config = new HttpConfiguration();
config.MessageHandlers.Add(new HeaderAppenderHandler());
config.MessageHandlers.Add(new EndRequestHandler());
config.Filters.Add(new ArticlesReversedFilter());
config.Services.Replace(typeof(IAssembliesResolver), new CustomAssembliesResolver());
config.Routes.MapHttpRoute(
name: "DefaultApi",
routeTemplate: "api/{controller}/{id}",
defaults: new { id = RouteParameter.Optional }
);
config.MapHttpAttributeRoutes();
// Autofac configuration
var builder = new ContainerBuilder();
builder.RegisterApiControllers(typeof(ArticlesController).Assembly);
// Unit of Work
var _unitOfWork = new Mock();
builder.RegisterInstance(_unitOfWork.Object).As();
//Repositories
var _articlesRepository = new Mock();
_articlesRepository.Setup(x => x.GetAll()).Returns(
BloggerInitializer.GetAllArticles()
);
builder.RegisterInstance(_articlesRepository.Object).As();
var _blogsRepository = new Mock();
_blogsRepository.Setup(x => x.GetAll()).Returns(
BloggerInitializer.GetBlogs
);
builder.RegisterInstance(_blogsRepository.Object).As();
// Services
builder.RegisterAssemblyTypes(typeof(ArticleService).Assembly)
.Where(t => t.Name.EndsWith("Service"))
.AsImplementedInterfaces().InstancePerRequest();
builder.RegisterInstance(new ArticleService(_articlesRepository.Object, _unitOfWork.Object));
builder.RegisterInstance(new BlogService(_blogsRepository.Object, _unitOfWork.Object));
IContainer container = builder.Build();
config.DependencyResolver = new AutofacWebApiDependencyResolver(container);
appBuilder.UseWebApi(config);
}
} 可能注意到和UnitTestingWebAPI.API里的WebSetup类的不同之处在与,这里我们用了假的Repositories和Services。
返回到ControllerTests.cs中。
[Test]
public void ShouldCallToControllerActionAppendCustomHeader()
{
//Arrange
var address = "http://localhost:9000/";
using (WebApp.Start(address))
{
HttpClient _client = new HttpClient();
var response = _client.GetAsync(address + "api/articles").Result;
Assert.That(response.Headers.Contains("X-WebAPI-Header"), Is.True);
var _returnedArticles = response.Content.ReadAsAsync>().Result;
Assert.That(_returnedArticles.Count, Is.EqualTo(BloggerInitializer.GetAllArticles().Count));
}
}
媒体类型格式化器 测试
我们在UnitTestingWebAPI.API.Core中创建了ArticleFormatter,现在测试一下,应该返回用逗号分割的文章字符串。它只能是写文章的实例,但不能读或者明白其它类型的类。为了应用这个格式化器需要设置请求头信息的Accept为application/article
[TestFixture]
public class MediaTypeFormatterTests
{
#region Variables
Blog _blog;
Article _article;
ArticleFormatter _formatter;
#endregion
#region Setup
[SetUp]
public void Setup()
{
_blog = BloggerInitializer.GetBlogs().First();
_article = BloggerInitializer.GetChsakellsArticles().First();
_formatter = new ArticleFormatter();
}
#endregion
}我们可以创建一个ObjectContent来测试MediaTypeFormatter,传递一个对象来检查是否能被被格式化,如果格式化器不能读和写传递过去的对象则会抛出异常,例如,文章的格式化器不能识别Blog对象:
[Test]
public void FormatterShouldThrowExceptionWhenUnsupportedType()
{
Assert.Throws(() => new ObjectContent(_blog, _formatter));
} 换句话说,传一个Article对象就一定会通过测试
[Test]
public void FormatterShouldNotThrowExceptionWhenArticle()
{
Assert.DoesNotThrow(() => new ObjectContent(_article, _formatter));
} 用下面的代码测试不符合MediaType formatter的Media type
Media Type Formatters Unit tests
[Test]
public void FormatterShouldHeaderBeSetCorrectly()
{
var content = new ObjectContent(_article, new ArticleFormatter());
Assert.That(content.Headers.ContentType.MediaType, Is.EqualTo("application/article"));
}
[Test]
public async void FormatterShouldBeAbleToDeserializeArticle()
{
var content = new ObjectContent(_article, _formatter);
var deserializedItem = await content.ReadAsAsync(new[] { _formatter });
Assert.That(_article, Is.SameAs(deserializedItem));
}
[Test]
public void FormatterShouldNotBeAbleToWriteUnsupportedType()
{
var canWriteBlog = _formatter.CanWriteType(typeof(Blog));
Assert.That(canWriteBlog, Is.False);
}
[Test]
public void FormatterShouldBeAbleToWriteArticle()
{
var canWriteArticle = _formatter.CanWriteType(typeof(Article));
Assert.That(canWriteArticle, Is.True);
} 路由测试
在不Host Web API的情况下,测试路由配置。为了这个目的,需要一个可以从HttpControllerContext的实例中返回Controllerl类型或Controller中Action的帮助类,在测试之前,先创建一个路由配置的HttpConfiguration
Helpers/ControllerActionSelector.cs
public class ControllerActionSelector
{
#region Variables
HttpConfiguration config;
HttpRequestMessage request;
IHttpRouteData routeData;
IHttpControllerSelector controllerSelector;
HttpControllerContext controllerContext;
#endregion
#region Constructor
public ControllerActionSelector(HttpConfiguration conf, HttpRequestMessage req)
{
config = conf;
request = req;
routeData = config.Routes.GetRouteData(request);
request.Properties[HttpPropertyKeys.HttpRouteDataKey] = routeData;
controllerSelector = new DefaultHttpControllerSelector(config);
controllerContext = new HttpControllerContext(config, routeData, request);
}
#endregion
#region Methods
public string GetActionName()
{
if (controllerContext.ControllerDescriptor == null)
GetControllerType();
var actionSelector = new ApiControllerActionSelector();
var descriptor = actionSelector.SelectAction(controllerContext);
return descriptor.ActionName;
}
public Type GetControllerType()
{
var descriptor = controllerSelector.SelectController(request);
controllerContext.ControllerDescriptor = descriptor;
return descriptor.ControllerType;
}
#endregion
}下面是路由测试:
[TestFixture]
public class RouteTests
{
#region Variables
HttpConfiguration _config;
#endregion
#region Setup
[SetUp]
public void Setup()
{
_config = new HttpConfiguration();
_config.Routes.MapHttpRoute(name: "DefaultWebAPI", routeTemplate: "api/{controller}/{id}", defaults: new { id = RouteParameter.Optional });
}
#endregion
#region Helper methods
public static string GetMethodName(Expression> expression)
{
var method = expression.Body as MethodCallExpression;
if (method != null)
return method.Method.Name;
throw new ArgumentException("Expression is wrong");
}
#endregion
} 测试一个请求api/articles/5到ArticleController的action GetArticle(int id)
[Test]
public void RouteShouldControllerGetArticleIsInvoked()
{
var request = new HttpRequestMessage(HttpMethod.Get, "http://www.chsakell.com/api/articles/5");
var _actionSelector = new ControllerActionSelector(_config, request);
Assert.That(typeof(ArticlesController), Is.EqualTo(_actionSelector.GetControllerType()));
Assert.That(GetMethodName((ArticlesController c) => c.GetArticle(5)),
Is.EqualTo(_actionSelector.GetActionName()));
}我们用反射得到controller的action名称,用同样的方法来测试post提交的action
[Test]
public void RouteShouldPostArticleActionIsInvoked()
{
var request = new HttpRequestMessage(HttpMethod.Post, "http://www.chsakell.com/api/articles/");
var _actionSelector = new ControllerActionSelector(_config, request);
Assert.That(GetMethodName((ArticlesController c) =>
c.PostArticle(new Article())), Is.EqualTo(_actionSelector.GetActionName()));
}下面这个测试,路由会发生异常.
[Test]
public void RouteShouldInvalidRouteThrowException()
{
var request = new HttpRequestMessage(HttpMethod.Post, "http://www.chsakell.com/api/InvalidController/");
var _actionSelector = new ControllerActionSelector(_config, request);
Assert.Throws(() => _actionSelector.GetActionName());
} 结论
我们看到了Web API栈很多方面的单元测试,例如: mocking 服务层,单元测试控制器,消息管道,过滤器,定制媒体类型和路由配置。
尝试在你的程序中总是写单元测试,你不会后悔的。从里面会得到很多的好处,例如:在repository中一个简单的修改可能破坏很多方面,如果写一个合适的测试,则可能破坏你程序的问题会立即出现.
原文:chsakell's Blog
创新互联www.cdcxhl.cn,专业提供香港、美国云服务器,动态BGP最优骨干路由自动选择,持续稳定高效的网络助力业务部署。公司持有工信部办法的idc、isp许可证, 机房独有T级流量清洗系统配攻击溯源,准确进行流量调度,确保服务器高可用性。佳节活动现已开启,新人活动云服务器买多久送多久。
名称栏目:ASP.NETWebAPI单元测试-单元测试-创新互联
网址分享:http://www.jxjierui.cn/article/ddoiog.html


咨询
建站咨询
