mirror of https://github.com/abpframework/abp.git
committed by
GitHub
45 changed files with 1928 additions and 241 deletions
@ -0,0 +1,128 @@ |
|||
# Bootstrap Modules |
|||
|
|||
ABP framework can be bootstrapped in various applications. like ASP NET Core or Console, WPF, etc. |
|||
|
|||
Modules can be started sync or async, Corresponds to various event methods in the module. We recommend using **async**, Especially if you want to make async calls in the module's methods. |
|||
|
|||
The `PreConfigureServices`, `ConfigureServices`, `PostConfigureServices`, `OnPreApplicationInitialization`, `OnApplicationInitialization`, `OnPostApplicationInitialization` methods of module have the Async version. |
|||
|
|||
> Async methods automatically call sync methods by default. |
|||
|
|||
If you use async methods in your module, please keep the same sync methods for compatibility. |
|||
|
|||
````csharp |
|||
public async override Task OnApplicationInitializationAsync(ApplicationInitializationContext context) |
|||
{ |
|||
await AsyncMethod(); |
|||
} |
|||
|
|||
public override void OnApplicationInitialization(ApplicationInitializationContext context) |
|||
{ |
|||
AsyncHelper.RunSync(() => OnApplicationInitializationAsync(context)); |
|||
} |
|||
```` |
|||
|
|||
## ASP NET Core |
|||
|
|||
Bootstrap ABP by using [WebApplication](https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.builder.webapplication?view=aspnetcore-6.0) |
|||
|
|||
````csharp |
|||
var builder = WebApplication.CreateBuilder(args); |
|||
builder.Host.AddAppSettingsSecretsJson() |
|||
.UseAutofac(); |
|||
|
|||
await builder.Services.AddApplicationAsync<MyProjectNameWebModule>(); |
|||
var app = builder.Build(); |
|||
|
|||
await app.InitializeApplicationAsync(); |
|||
await app.RunAsync(); |
|||
```` |
|||
|
|||
## Console |
|||
|
|||
Bootstrap ABP in Console App. |
|||
|
|||
````csharp |
|||
var abpApplication = await AbpApplicationFactory.CreateAsync<MyProjectNameModule>(options => |
|||
{ |
|||
options.UseAutofac(); |
|||
}); |
|||
|
|||
await _abpApplication.InitializeAsync(); |
|||
|
|||
var helloWorldService = _abpApplication.ServiceProvider.GetRequiredService<HelloWorldService>(); |
|||
|
|||
await helloWorldService.SayHelloAsync(); |
|||
```` |
|||
|
|||
Bootstrap ABP by using [HostedService](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-6.0&tabs=visual-studio#ihostedservice-interface) |
|||
|
|||
````csharp |
|||
public class MyHostedService : IHostedService |
|||
{ |
|||
private IAbpApplicationWithInternalServiceProvider _abpApplication; |
|||
|
|||
private readonly IConfiguration _configuration; |
|||
private readonly IHostEnvironment _hostEnvironment; |
|||
|
|||
public MyHostedService(IConfiguration configuration, IHostEnvironment hostEnvironment) |
|||
{ |
|||
_configuration = configuration; |
|||
_hostEnvironment = hostEnvironment; |
|||
} |
|||
|
|||
public async Task StartAsync(CancellationToken cancellationToken) |
|||
{ |
|||
_abpApplication = await AbpApplicationFactory.CreateAsync<MyProjectNameModule>(options => |
|||
{ |
|||
options.Services.ReplaceConfiguration(_configuration); |
|||
options.Services.AddSingleton(_hostEnvironment); |
|||
|
|||
options.UseAutofac(); |
|||
options.Services.AddLogging(loggingBuilder => loggingBuilder.AddSerilog()); |
|||
}); |
|||
|
|||
await _abpApplication.InitializeAsync(); |
|||
|
|||
var helloWorldService = _abpApplication.ServiceProvider.GetRequiredService<HelloWorldService>(); |
|||
|
|||
await helloWorldService.SayHelloAsync(); |
|||
} |
|||
|
|||
public async Task StopAsync(CancellationToken cancellationToken) |
|||
{ |
|||
await _abpApplication.ShutdownAsync(); |
|||
} |
|||
} |
|||
```` |
|||
|
|||
## WPF |
|||
|
|||
Bootstrap ABP on [OnStartup](https://docs.microsoft.com/en-us/dotnet/api/system.windows.application.onstartup?view=windowsdesktop-6.0) method. |
|||
|
|||
````csharp |
|||
public partial class App : Application |
|||
{ |
|||
private IAbpApplicationWithInternalServiceProvider _abpApplication; |
|||
|
|||
protected async override void OnStartup(StartupEventArgs e) |
|||
{ |
|||
_abpApplication = await AbpApplicationFactory.CreateAsync<MyProjectNameModule>(options => |
|||
{ |
|||
options.UseAutofac(); |
|||
options.Services.AddLogging(loggingBuilder => loggingBuilder.AddSerilog(dispose: true)); |
|||
}); |
|||
|
|||
await _abpApplication.InitializeAsync(); |
|||
|
|||
_abpApplication.Services.GetRequiredService<MainWindow>()?.Show(); |
|||
} |
|||
|
|||
protected async override void OnExit(ExitEventArgs e) |
|||
{ |
|||
await _abpApplication.ShutdownAsync(); |
|||
} |
|||
} |
|||
|
|||
```` |
|||
|
|||
@ -0,0 +1,768 @@ |
|||
# 自动化测试 |
|||
|
|||
## 介绍 |
|||
|
|||
ABP框架的设计考虑了可测试性. 有一些不同级别的自动化测试: |
|||
|
|||
* **单元测试**: 通常只测试一个类(或者一起测试几个类). 这些测试会很快. 然而, 你通常需要处理对服务依赖项的模拟. |
|||
* **集成测试**: 你通常会测试一个服务, 但这一次你不会模拟基本的基础设施和服务, 以查看它们是否正确地协同工作. |
|||
* **用户界面测试**: 测试应用程序的UI, 就像用户与应用程序交互一样. |
|||
|
|||
### 单元测试 vs 集成测试 |
|||
|
|||
与单元测试相比, 集成测试有一些显著的**优势**: |
|||
|
|||
* **编写更加简单** 因为你不需要模拟和处理依赖关系. |
|||
* 你的测试代码运行于所有真正的服务和基础设施(包括数据库映射和查询), 因此它更接近于**真正的应用程序测试**. |
|||
|
|||
同时它们有一些缺点: |
|||
|
|||
* 与单元测试相比, 它们**更慢**, 因为所有的基础设施都准备好了测试用例. |
|||
* 服务中的一个bug可能会导致多个测试用例失败, 因此在某些情况下, 可能会**更难找到真正的问题**. |
|||
|
|||
我们建议混合使用: 在必要的地方编写单元测试或集成测试, 并且有效的编写和维护它. |
|||
|
|||
## 应用程序启动模板 |
|||
|
|||
测试基础设施提供[应用程序启动模板](Startup-Templates/Application.md) , 并已经正确安装和配置. |
|||
|
|||
### 测试项目 |
|||
|
|||
请参见Visual Studio中的以下解决方案: |
|||
|
|||
 |
|||
|
|||
按层级系统分为多个测试项目: |
|||
|
|||
* `Domain.Tests` 用于测试领域层对象 (例如[领域服务](Domain-Services.md) 和 [实体](Entities.md)). |
|||
* `Application.Tests` 用于测试应用层对象 (例如[应用服务](Application-Services.md)). |
|||
* `EntityFrameworkCore.Tests` 用于测试你的自定义仓储实现或EF Core映射(如果你使用其他[数据访问](Data-Access.md))的话, 该项目将有所不同). |
|||
* `Web.Tests` 用于测试UI层(如页面、控制器和视图组件). 该项目仅适用于MVC / Razor页面应用程序. |
|||
* `TestBase` 包含一些由其他项目共享/使用的类. |
|||
|
|||
> `HttpApi.Client.ConsoleTestApp` 不是自动化测试的应用程序. 它是一个示例的控制台应用程序, 展示了如何从.NET控制台应用程序中调用HTTP API. |
|||
|
|||
以下的部分将介绍这些项目中包含的基类和其他基础设施. |
|||
|
|||
### 测试基础设施 |
|||
|
|||
解决方案中已经安装了以下库: |
|||
|
|||
* [xUnit](https://xunit.net/) 作为测试框架. |
|||
* [NSubstitute](https://nsubstitute.github.io/) 用于模拟. |
|||
* [Shouldly](https://github.com/shouldly/shouldly) 用于断言. |
|||
|
|||
虽然你可以用自己喜欢的工具替换它们, 但本文档和示例将基于这些工具. |
|||
|
|||
## 测试资源管理器 |
|||
|
|||
你可以在Visual Studio中使用测试资源管理器查看和运行测试. 其他IDE, 请参阅它们自己的文档. |
|||
|
|||
### 打开测试资源管理器 |
|||
|
|||
打开*测试*菜单下的*测试资源管理器*(如果尚未打开): |
|||
|
|||
 |
|||
|
|||
### 运行测试 |
|||
|
|||
然后, 你可以单击在视图中运行所有测试或运行按钮来运行测试. 初始启动模板为你提供了一些测试用例: |
|||
|
|||
 |
|||
|
|||
### 并行运行测试 |
|||
|
|||
支持并行运行测试. **强烈建议**并行运行所有测试, 这比逐个运行测试要快得多. |
|||
|
|||
要启用它, 请单击设置(齿轮)按钮附近的插入符号图标, 然后选择*并行运行测试*. |
|||
|
|||
 |
|||
|
|||
## 单元测试 |
|||
|
|||
对于单元测试, 不需要太多的配置. 通常会实例化你的类, 并对要测试的对象提供一些预先配置的模拟对象. |
|||
|
|||
### 没有依赖项的类 |
|||
|
|||
要测试的类没有依赖项是最简单的情况, 你可以直接实例化类, 调用其方法并做出断言. |
|||
|
|||
#### 示例: 测试实体 |
|||
|
|||
假设你有一个 `Issue` [实体](Entities.md), 如下所示: |
|||
|
|||
````csharp |
|||
using System; |
|||
using Volo.Abp.Domain.Entities; |
|||
|
|||
namespace MyProject.Issues |
|||
{ |
|||
public class Issue : AggregateRoot<Guid> |
|||
{ |
|||
public string Title { get; set; } |
|||
public string Description { get; set; } |
|||
public bool IsLocked { get; set; } |
|||
public bool IsClosed { get; private set; } |
|||
public DateTime? CloseDate { get; private set; } |
|||
|
|||
public void Close() |
|||
{ |
|||
IsClosed = true; |
|||
CloseDate = DateTime.UtcNow; |
|||
} |
|||
|
|||
public void Open() |
|||
{ |
|||
if (!IsClosed) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
if (IsLocked) |
|||
{ |
|||
throw new IssueStateException("You can not open a locked issue!"); |
|||
} |
|||
|
|||
IsClosed = true; |
|||
CloseDate = null; |
|||
} |
|||
} |
|||
} |
|||
|
|||
```` |
|||
|
|||
请注意, `IsClosed`和`CloseDate`属性具有私有setter, 可以使用`Open()`和`Close()`方法强制执行某些业务逻辑: |
|||
|
|||
* 无论何时关闭issue, `CloseDate`都应设置为[当前时间](Timing.md). |
|||
* 如果issue被锁定, 则无法重新打开. 如果它被重新打开, `CloseDate`应该设置为`null`. |
|||
|
|||
由于`Issue`实体是领域层的一部分, 所以我们应该在`Domain.Tests`项目中测试它. 在`Domain.Tests`项目中创建一个`Issue_Tests`类: |
|||
|
|||
````csharp |
|||
using Shouldly; |
|||
using Xunit; |
|||
|
|||
namespace MyProject.Issues |
|||
{ |
|||
public class Issue_Tests |
|||
{ |
|||
[Fact] |
|||
public void Should_Set_The_CloseDate_Whenever_Close_An_Issue() |
|||
{ |
|||
// Arrange |
|||
|
|||
var issue = new Issue(); |
|||
issue.CloseDate.ShouldBeNull(); // null at the beginning |
|||
|
|||
// Act |
|||
|
|||
issue.Close(); |
|||
|
|||
// Assert |
|||
|
|||
issue.IsClosed.ShouldBeTrue(); |
|||
issue.CloseDate.ShouldNotBeNull(); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
这个测试遵循AAA(Arrange-Act-Assert)模式: |
|||
|
|||
* **Arrange** 部分创建一个`Issue`实体, 并确保`CloseDate`在初始值为`null`. |
|||
* **Act** 部分执行我们想要测试的方法. |
|||
* **Assert** 部分检查`Issue`属性是否与我们预期的相同. |
|||
|
|||
`[Fact]`属性由[xUnit](https://xunit.net/)并将方法标记为测试方法. `Should...`扩展方法由[Shouldly](https://github.com/shouldly/shouldly)提供. 你可以直接使用xUnit中的`Assert`类, 使用Shouldly让它更舒适、更直观. |
|||
|
|||
当你执行测试时, 你将看到它成功通过: |
|||
|
|||
 |
|||
|
|||
让我们再添加两种测试方法: |
|||
|
|||
````csharp |
|||
[Fact] |
|||
public void Should_Allow_To_ReOpen_An_Issue() |
|||
{ |
|||
// Arrange |
|||
|
|||
var issue = new Issue(); |
|||
issue.Close(); |
|||
|
|||
// Act |
|||
|
|||
issue.Open(); |
|||
|
|||
// Assert |
|||
|
|||
issue.IsClosed.ShouldBeFalse(); |
|||
issue.CloseDate.ShouldBeNull(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Not_Allow_To_ReOpen_A_Locked_Issue() |
|||
{ |
|||
// Arrange |
|||
|
|||
var issue = new Issue(); |
|||
issue.Close(); |
|||
issue.IsLocked = true; |
|||
|
|||
// Act & Assert |
|||
|
|||
Assert.Throws<IssueStateException>(() => |
|||
{ |
|||
issue.Open(); |
|||
}); |
|||
} |
|||
```` |
|||
|
|||
`Assert.Throws` 检查执行的代码是否匹配引发的异常. |
|||
|
|||
> 有关这些库的更多信息, 请参阅xUnit & Shoudly的文档. |
|||
|
|||
### 具有依赖项的类 |
|||
|
|||
如果你的服务中有依赖项, 并且你想对该服务进行单元测试, 那么你需要模拟这些依赖项. |
|||
|
|||
#### 示例: 测试领域服务 |
|||
|
|||
假设你有一个`IssueManager` [领域服务](Domain-Services.md), 定义如下: |
|||
|
|||
````csharp |
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp; |
|||
using Volo.Abp.Domain.Services; |
|||
|
|||
namespace MyProject.Issues |
|||
{ |
|||
public class IssueManager : DomainService |
|||
{ |
|||
public const int MaxAllowedOpenIssueCountForAUser = 3; |
|||
|
|||
private readonly IIssueRepository _issueRepository; |
|||
|
|||
public IssueManager(IIssueRepository issueRepository) |
|||
{ |
|||
_issueRepository = issueRepository; |
|||
} |
|||
|
|||
public async Task AssignToUserAsync(Issue issue, Guid userId) |
|||
{ |
|||
var issueCount = await _issueRepository.GetIssueCountOfUserAsync(userId); |
|||
|
|||
if (issueCount >= MaxAllowedOpenIssueCountForAUser) |
|||
{ |
|||
throw new BusinessException( |
|||
code: "IM:00392", |
|||
message: $"You can not assign more" + |
|||
$"than {MaxAllowedOpenIssueCountForAUser} issues to a user!" |
|||
); |
|||
} |
|||
|
|||
issue.AssignedUserId = userId; |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
`IssueManager`依赖于`IssueRepository`服务, 在本例中将模拟该服务. |
|||
|
|||
**业务逻辑**: 示例`AssignToUserAsync`不允许向用户分配超过3个issue (`MaxAllowedOpenIssueCountForAUser`常量). 在这种情况下, 如果要分配issue, 首先需要取消现有issue的分配. |
|||
|
|||
下面的测试用例给出一个有效的赋值: |
|||
|
|||
````csharp |
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using NSubstitute; |
|||
using Shouldly; |
|||
using Volo.Abp; |
|||
using Xunit; |
|||
|
|||
namespace MyProject.Issues |
|||
{ |
|||
public class IssueManager_Tests |
|||
{ |
|||
[Fact] |
|||
public async Task Should_Assign_An_Issue_To_A_User() |
|||
{ |
|||
// Arrange |
|||
|
|||
var userId = Guid.NewGuid(); |
|||
|
|||
var fakeRepo = Substitute.For<IIssueRepository>(); |
|||
fakeRepo.GetIssueCountOfUserAsync(userId).Returns(1); |
|||
|
|||
var issueManager = new IssueManager(fakeRepo); |
|||
|
|||
var issue = new Issue(); |
|||
|
|||
// Act |
|||
|
|||
await issueManager.AssignToUserAsync(issue, userId); |
|||
|
|||
//Assert |
|||
|
|||
issue.AssignedUserId.ShouldBe(userId); |
|||
await fakeRepo.Received(1).GetIssueCountOfUserAsync(userId); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
* `Substitute.For<IIssueRepository>` 创建一个模拟(假)对象, 该对象被传递到`IssueManager`构造函数中. |
|||
* `fakeRepo.GetIssueCountOfUserAsync(userId).Returns(1)` 确保仓储中的`GetIssueContofuseRasync`方法返回`1`. |
|||
* `issueManager.AssignToUserAsync` 不会引发任何异常, 因为仓储统计当前分配的issue数量并且返回`1`. |
|||
* `issue.AssignedUserId.ShouldBe(userId);` 行检查`AssignedUserId`的值是否正确. |
|||
* `await fakeRepo.Received(1).GetIssueCountOfUserAsync(userId);` 检查 `IssueManager` 实际只调用了 `GetIssueCountOfUserAsync` 方法一次. |
|||
|
|||
让我们添加第二个测试, 看看它是否能阻止将issue分配给超过分配数量的用户: |
|||
|
|||
````csharp |
|||
[Fact] |
|||
public async Task Should_Not_Allow_To_Assign_Issues_Over_The_Limit() |
|||
{ |
|||
// Arrange |
|||
|
|||
var userId = Guid.NewGuid(); |
|||
|
|||
var fakeRepo = Substitute.For<IIssueRepository>(); |
|||
fakeRepo |
|||
.GetIssueCountOfUserAsync(userId) |
|||
.Returns(IssueManager.MaxAllowedOpenIssueCountForAUser); |
|||
|
|||
var issueManager = new IssueManager(fakeRepo); |
|||
|
|||
// Act & Assert |
|||
|
|||
var issue = new Issue(); |
|||
|
|||
await Assert.ThrowsAsync<BusinessException>(async () => |
|||
{ |
|||
await issueManager.AssignToUserAsync(issue, userId); |
|||
}); |
|||
|
|||
issue.AssignedUserId.ShouldBeNull(); |
|||
await fakeRepo.Received(1).GetIssueCountOfUserAsync(userId); |
|||
} |
|||
```` |
|||
|
|||
> 有关模拟的更多信息, 请参阅[NSubstitute](https://nsubstitute.github.io/)文档. |
|||
|
|||
模拟单个依赖项相对容易. 但是, 当依赖关系增长时, 设置测试对象和模拟所有依赖关系变得越来越困难. 请参阅不需要模拟依赖项的*Integration Tests*部分. |
|||
|
|||
### 提示: 共享测试类构造函数 |
|||
|
|||
[xUnit](https://xunit.net/) 为每个测试方法创建一个**新测试类实例**(本例中为`IssueManager_Tests`). 因此, 你可以将一些*Arrange*代码移动到构造函数中, 以减少代码重复. 构造函数将针对每个测试用例执行, 并且不会相互影响, 即使它们是并行工作. |
|||
|
|||
**示例: 重构`IssueManager_Tests`以减少代码重复** |
|||
|
|||
````csharp |
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using NSubstitute; |
|||
using Shouldly; |
|||
using Volo.Abp; |
|||
using Xunit; |
|||
|
|||
namespace MyProject.Issues |
|||
{ |
|||
public class IssueManager_Tests |
|||
{ |
|||
private readonly Guid _userId; |
|||
private readonly IIssueRepository _fakeRepo; |
|||
private readonly IssueManager _issueManager; |
|||
private readonly Issue _issue; |
|||
|
|||
public IssueManager_Tests() |
|||
{ |
|||
_userId = Guid.NewGuid(); |
|||
_fakeRepo = Substitute.For<IIssueRepository>(); |
|||
_issueManager = new IssueManager(_fakeRepo); |
|||
_issue = new Issue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Assign_An_Issue_To_A_User() |
|||
{ |
|||
// Arrange |
|||
_fakeRepo.GetIssueCountOfUserAsync(_userId).Returns(1); |
|||
|
|||
// Act |
|||
await _issueManager.AssignToUserAsync(_issue, _userId); |
|||
|
|||
//Assert |
|||
_issue.AssignedUserId.ShouldBe(_userId); |
|||
await _fakeRepo.Received(1).GetIssueCountOfUserAsync(_userId); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Not_Allow_To_Assign_Issues_Over_The_Limit() |
|||
{ |
|||
// Arrange |
|||
_fakeRepo |
|||
.GetIssueCountOfUserAsync(_userId) |
|||
.Returns(IssueManager.MaxAllowedOpenIssueCountForAUser); |
|||
|
|||
// Act & Assert |
|||
await Assert.ThrowsAsync<BusinessException>(async () => |
|||
{ |
|||
await _issueManager.AssignToUserAsync(_issue, _userId); |
|||
}); |
|||
|
|||
_issue.AssignedUserId.ShouldBeNull(); |
|||
await _fakeRepo.Received(1).GetIssueCountOfUserAsync(_userId); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
> 保持测试代码整洁, 以创建可维护的测试组件. |
|||
|
|||
## 集成测试 |
|||
|
|||
> 你还可以按照[Web应用程序开发教程](Tutorials/Part-1.md)学习开发全栈应用程序, 包括集成测试. |
|||
|
|||
### 集成测试基础 |
|||
|
|||
ABP为编写集成测试提供了完整的基础设施. 所有ABP基础设施和服务都将在你的测试中执行. 应用程序启动模板附带了为你预先配置的必要基础设施; |
|||
|
|||
#### 数据库 |
|||
|
|||
启动模板使用EF Core配置**内存中的SQLite**数据库(对于MongoDB, 它使用[Mongo2Go](https://github.com/Mongo2Go/Mongo2Go)). 因此, 所有配置和查询都是针对真实数据库执行的, 你甚至可以测试数据库事务. |
|||
|
|||
使用内存中的SQLite数据库有两个主要优点: |
|||
|
|||
* 它比外部DBMS更快. |
|||
* 它会为每个测试用例创建一个**新的数据库**, 这样测试就不会相互影响. |
|||
|
|||
> **提示**: 不要将EF Core的内存数据库用于高级集成测试. 它不是一个真正的DBMS, 在细节上有很多不同. 例如, 它不支持事务和回滚场景, 因此无法真正测试失败的场景. 另一方面, 内存中的SQLite是一个真正的DBMS, 支持SQL数据库的基本功能. |
|||
|
|||
### 种子数据 |
|||
|
|||
针对空数据库编写测试是不现实的. 在大多数情况下, 需要在数据库中保存一些初始数据. 例如, 如果你编写了一个查询、更新和删除产品的测试类, 那么在执行测试用例之前, 在数据库中有一些产品数据会很有帮助. |
|||
|
|||
ABP的[种子数据](Data-Seeding.md)系统是一种强大的初始化数据的方法. 应用程序启动模板在`.TestBase`项目中有一个*YourProject*TestDataSeedContributor类. 你可以在其中添加, 以获得可用于每个测试方法的初始数据. |
|||
|
|||
**示例: 创建一些Issue作为种子数据** |
|||
|
|||
````csharp |
|||
using System.Threading.Tasks; |
|||
using MyProject.Issues; |
|||
using Volo.Abp.Data; |
|||
using Volo.Abp.DependencyInjection; |
|||
|
|||
namespace MyProject |
|||
{ |
|||
public class MyProjectTestDataSeedContributor |
|||
: IDataSeedContributor, ITransientDependency |
|||
{ |
|||
private readonly IIssueRepository _issueRepository; |
|||
|
|||
public MyProjectTestDataSeedContributor(IIssueRepository issueRepository) |
|||
{ |
|||
_issueRepository = issueRepository; |
|||
} |
|||
|
|||
public async Task SeedAsync(DataSeedContext context) |
|||
{ |
|||
await _issueRepository.InsertAsync( |
|||
new Issue |
|||
{ |
|||
Title = "Test issue one", |
|||
Description = "Test issue one description", |
|||
AssignedUserId = TestData.User1Id |
|||
}); |
|||
|
|||
await _issueRepository.InsertAsync( |
|||
new Issue |
|||
{ |
|||
Title = "Test issue two", |
|||
Description = "Test issue two description", |
|||
AssignedUserId = TestData.User1Id |
|||
}); |
|||
|
|||
await _issueRepository.InsertAsync( |
|||
new Issue |
|||
{ |
|||
Title = "Test issue three", |
|||
Description = "Test issue three description", |
|||
AssignedUserId = TestData.User1Id |
|||
}); |
|||
|
|||
await _issueRepository.InsertAsync( |
|||
new Issue |
|||
{ |
|||
Title = "Test issue four", |
|||
Description = "Test issue four description", |
|||
AssignedUserId = TestData.User2Id |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
还创建了一个静态类来存储用户的 `Id`: |
|||
|
|||
````csharp |
|||
using System; |
|||
|
|||
namespace MyProject |
|||
{ |
|||
public static class TestData |
|||
{ |
|||
public static Guid User1Id = Guid.Parse("41951813-5CF9-4204-8B18-CD765DBCBC9B"); |
|||
public static Guid User2Id = Guid.Parse("2DAB4460-C21B-4925-BF41-A52750A9B999"); |
|||
} |
|||
} |
|||
```` |
|||
|
|||
通过这种方式, 我们可以使用这些已知Issue和用户的`Id`来运行测试. |
|||
|
|||
### 示例: 测试领域服务 |
|||
|
|||
`AbpIntegratedTest<T>`类 (定义在[Volo.Abp.TestBase](https://www.nuget.org/packages/Volo.Abp.TestBase)) 用于编写集成到ABP框架的测试. `T`是用于设置和初始化应用程序的根模块的类型. |
|||
|
|||
应用程序启动模板在每个测试项目中都有基类, 因此你可以从这些基类派生, 以使其更简单. |
|||
|
|||
`IssueManager`测试将被重写成集成测试 |
|||
|
|||
````csharp |
|||
using System.Threading.Tasks; |
|||
using Shouldly; |
|||
using Volo.Abp; |
|||
using Xunit; |
|||
|
|||
namespace MyProject.Issues |
|||
{ |
|||
public class IssueManager_Integration_Tests : MyProjectDomainTestBase |
|||
{ |
|||
private readonly IssueManager _issueManager; |
|||
private readonly Issue _issue; |
|||
|
|||
public IssueManager_Integration_Tests() |
|||
{ |
|||
_issueManager = GetRequiredService<IssueManager>(); |
|||
_issue = new Issue |
|||
{ |
|||
Title = "Test title", |
|||
Description = "Test description" |
|||
}; |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Not_Allow_To_Assign_Issues_Over_The_Limit() |
|||
{ |
|||
// Act & Assert |
|||
await Assert.ThrowsAsync<BusinessException>(async () => |
|||
{ |
|||
await _issueManager.AssignToUserAsync(_issue, TestData.User1Id); |
|||
}); |
|||
|
|||
_issue.AssignedUserId.ShouldBeNull(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Assign_An_Issue_To_A_User() |
|||
{ |
|||
// Act |
|||
await _issueManager.AssignToUserAsync(_issue, TestData.User2Id); |
|||
|
|||
//Assert |
|||
_issue.AssignedUserId.ShouldBe(TestData.User2Id); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
* 第一个测试方法将issue分配给User1, 其中User1已经分配了种子数据代码中的3个issue. 因此, 它抛出了一个`BusinessException`. |
|||
* 第二种测试方法将issue分配给User2, User2只分配了一个issue. 因此, 该方法成功了. |
|||
|
|||
这个类通常位于`.Domain.Tests`项目中, 因为它测试位于`.Domain`项目中的类. 它派生自`MyProjectDomainTestBase`, 并已经为正确运行测试进行了配置. |
|||
|
|||
编写这样一个集成测试类非常简单. 另一个好处是, 在以后向`IssueManager`类添加另一个依赖项时, 不需要更改测试类. |
|||
|
|||
### 示例: 测试应用服务 |
|||
|
|||
测试[应用服务](Application-Services.md)并没有太大的不同. 假设你已经创建了一个`IssueAppService`, 定义如下: |
|||
|
|||
````csharp |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.Application.Services; |
|||
|
|||
namespace MyProject.Issues |
|||
{ |
|||
public class IssueAppService : ApplicationService, IIssueAppService |
|||
{ |
|||
private readonly IIssueRepository _issueRepository; |
|||
|
|||
public IssueAppService(IIssueRepository issueRepository) |
|||
{ |
|||
_issueRepository = issueRepository; |
|||
} |
|||
|
|||
public async Task<List<IssueDto>> GetListAsync() |
|||
{ |
|||
var issues = await _issueRepository.GetListAsync(); |
|||
|
|||
return ObjectMapper.Map<List<Issue>, List<IssueDto>>(issues); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
*(假设你还定义了`IIssueAppService`和`IssueDto`, 并在`Issue`和`IssueDto`之间创建了[对象映射](Object-To-Object-Mapping.md))* |
|||
|
|||
现在, 你可以在`.Application.Tests`项目中编写一个测试类: |
|||
|
|||
````csharp |
|||
using System.Threading.Tasks; |
|||
using Shouldly; |
|||
using Xunit; |
|||
|
|||
namespace MyProject.Issues |
|||
{ |
|||
public class IssueAppService_Tests : MyProjectApplicationTestBase |
|||
{ |
|||
private readonly IIssueAppService _issueAppService; |
|||
|
|||
public IssueAppService_Tests() |
|||
{ |
|||
_issueAppService = GetRequiredService<IIssueAppService>(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Get_All_Issues() |
|||
{ |
|||
//Act |
|||
var issueDtos = await _issueAppService.GetListAsync(); |
|||
|
|||
//Assert |
|||
issueDtos.Count.ShouldBeGreaterThan(0); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
就这么简单. 此测试方法测试的所有内容, 包括应用服务、EF Core映射、对象到对象映射和仓储实现. 通过这种方式, 你可以完全测试解决方案的应用层和领域层. |
|||
|
|||
### 处理集成测试中的工作单元 |
|||
|
|||
ABP的[工作单元](Unit-Of-Work.md)系统控制应用程序中的数据库连接和事务管理. 它可以在你编写应用程序代码时无缝工作, 因此你可能没有意识到它. |
|||
|
|||
在ABP框架中, 所有数据库操作都必须在一个工作单元作用域内执行. 当你测试[应用服务](Application-Services.md)方法时, 工作单元的作用域将是应用服务方法的作用域. 如果你正在测试[仓储](Repositories.md)方法, 那么工作单元作用域将是你的仓储方法的作用域. |
|||
|
|||
在某些情况下, 你可能需要手动控制工作单元作用域. 可以考虑下面的测试方法: |
|||
|
|||
````csharp |
|||
public class IssueRepository_Tests : MyProjectDomainTestBase |
|||
{ |
|||
private readonly IRepository<Issue, Guid> _issueRepository; |
|||
|
|||
public IssueRepository_Tests() |
|||
{ |
|||
_issueRepository = GetRequiredService<IRepository<Issue, Guid>>(); |
|||
} |
|||
|
|||
public async Task Should_Query_By_Title() |
|||
{ |
|||
IQueryable<Issue> queryable = await _issueRepository.GetQueryableAsync(); |
|||
var issue = queryable.FirstOrDefaultAsync(i => i.Title == "My issue title"); |
|||
issue.ShouldNotBeNull(); |
|||
} |
|||
} |
|||
```` |
|||
|
|||
我们正在使用`_issueRepository.GetQueryableAsync`获取`IQueryable<Issue>` 对象. 然后, 我们使用`FirstOrDefaultAsync`方法按标题查询issue. 此时执行数据库查询, 你将会得到一个异常, 表明没有起作用的工作单元. |
|||
|
|||
要使该测试正常工作, 你应该手动启动工作单元作用域, 如下所示: |
|||
|
|||
````csharp |
|||
public class IssueRepository_Tests : MyProjectDomainTestBase |
|||
{ |
|||
private readonly IRepository<Issue, Guid> _issueRepository; |
|||
private readonly IUnitOfWorkManager _unitOfWorkManager; |
|||
|
|||
public IssueRepository_Tests() |
|||
{ |
|||
_issueRepository = GetRequiredService<IRepository<Issue, Guid>>(); |
|||
_unitOfWorkManager = GetRequiredService<IUnitOfWorkManager>(); |
|||
} |
|||
|
|||
public async Task Should_Query_By_Title() |
|||
{ |
|||
using (var uow = _unitOfWorkManager.Begin()) |
|||
{ |
|||
IQueryable<Issue> queryable = await _issueRepository.GetQueryableAsync(); |
|||
var issue = queryable.FirstOrDefaultAsync(i => i.Title == "My issue title"); |
|||
issue.ShouldNotBeNull(); |
|||
await uow.CompleteAsync(); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
我们已经使用了`IUnitOfWorkManager`服务来创建一个工作单元作用域, 然后在该作用域内调用了`FirstOrDefaultAsync`方法, 所以不再有问题了. |
|||
|
|||
> 请注意, 我们测试了`FirstOrDefaultAsync`来演示工作单元的问题. 作为一个好的标准, 编写自己的代码. |
|||
|
|||
### 使用DbContext |
|||
|
|||
在某些情况下, 你可能希望使用Entity Framework的`DbContext`对象来执行测试方法中的数据库操作. 在这种情况下, 可以使用`IDbContextProvider<T>`服务在工作单元内获取`DbContext`实例. |
|||
|
|||
下面的示例展示了如何在测试方法中创建`DbContext`对象: |
|||
|
|||
````csharp |
|||
public class MyDbContext_Tests : MyProjectDomainTestBase |
|||
{ |
|||
private readonly IDbContextProvider<MyProjectDbContext> _dbContextProvider; |
|||
private readonly IUnitOfWorkManager _unitOfWorkManager; |
|||
|
|||
public IssueRepository_Tests() |
|||
{ |
|||
_dbContextProvider = GetRequiredService<IDbContextProvider<MyProjectDbContext>>(); |
|||
_unitOfWorkManager = GetRequiredService<IUnitOfWorkManager>(); |
|||
} |
|||
|
|||
public async Task Should_Query_By_Title() |
|||
{ |
|||
using (var uow = _unitOfWorkManager.Begin()) |
|||
{ |
|||
var dbContext = await _dbContextProvider.GetDbContextAsync(); |
|||
var issue = await dbContext.Issues.FirstOrDefaultAsync(i => i.Title == "My issue title"); |
|||
issue.ShouldNotBeNull(); |
|||
await uow.CompleteAsync(); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
就像我们在*集成测试中处理工作单元*一节中所做的那样, 我们应该在起作用的工作单元内执行`DbContext`操作. |
|||
|
|||
对于[MongoDB](MongoDB.md), 你可以使用`IMongoDbContextProvider<T>`服务获取`DbContext`对象, 并在测试方法中直接使用MongoDB APIs. |
|||
|
|||
## 用户界面测试 |
|||
|
|||
一般来说, 有两种类型的UI测试: |
|||
|
|||
### 非可视化测试 |
|||
|
|||
此类测试完全取决于UI框架的选择: |
|||
|
|||
* 对于MVC / Razor页面UI, 通常向服务器发出请求, 获取HTML, 并测试返回的结果中是否存在一些预期的DOM元素. |
|||
* Angular有自己的基础设施和实践来测试组件、视图和服务. |
|||
|
|||
请参阅以下文档以了解非可视化UI测试: |
|||
|
|||
* [Testing in ASP.NET Core MVC / Razor Pages](UI/AspNetCore/Testing.md) |
|||
* [Testing in Angular](UI/Angular/Testing.md) |
|||
* [Testing in Blazor](UI/Blazor/Testing.md) |
|||
|
|||
### 可视化测试 |
|||
|
|||
与真实用户一样, 可视化测试用于与应用程序UI交互. 它全面测试应用程序, 包括页面和组件的外观. |
|||
|
|||
可视化UI测试超出了ABP框架的范围. 行业中有很多工具(比如[Selenium](https://www.selenium.dev/))可以用来测试应用程序的UI. |
|||
@ -0,0 +1,380 @@ |
|||
# Angular UI 单元测试 |
|||
|
|||
ABP Angular UI的测试与其他Angular应用程序一样. 所以, [这里的指南](https://angular.io/guide/testing)也适用于ABP. 也就是说, 我们想指出一些**特定于ABP Angular应用程序的单元测试内容**. |
|||
|
|||
## 设置 |
|||
|
|||
在Angular中, 单元测试默认使用[Karma](https://karma-runner.github.io/)和[Jasmine](https://jasmine.github.io). 虽然我们更喜欢Jest, 但我们选择不偏离这些默认设置, 因此**你下载的应用程序模板将预先配置Karma和Jasmine**. 你可以在根目录中的 _karma.conf.js_ 文件中找到Karma配置. 你什么都不用做. 添加一个spec文件并运行`npm test`即可. |
|||
|
|||
## 基础 |
|||
|
|||
简化版的spec文件如下所示: |
|||
|
|||
```js |
|||
import { CoreTestingModule } from "@abp/ng.core/testing"; |
|||
import { ThemeBasicTestingModule } from "@abp/ng.theme.basic/testing"; |
|||
import { ThemeSharedTestingModule } from "@abp/ng.theme.shared/testing"; |
|||
import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; |
|||
import { NgxValidateCoreModule } from "@ngx-validate/core"; |
|||
import { MyComponent } from "./my.component"; |
|||
|
|||
describe("MyComponent", () => { |
|||
let fixture: ComponentFixture<MyComponent>; |
|||
|
|||
beforeEach( |
|||
waitForAsync(() => { |
|||
TestBed.configureTestingModule({ |
|||
declarations: [MyComponent], |
|||
imports: [ |
|||
CoreTestingModule.withConfig(), |
|||
ThemeSharedTestingModule.withConfig(), |
|||
ThemeBasicTestingModule.withConfig(), |
|||
NgxValidateCoreModule, |
|||
], |
|||
providers: [ |
|||
/* mock providers here */ |
|||
], |
|||
}).compileComponents(); |
|||
}) |
|||
); |
|||
|
|||
beforeEach(() => { |
|||
fixture = TestBed.createComponent(MyComponent); |
|||
fixture.detectChanges(); |
|||
}); |
|||
|
|||
it("should be initiated", () => { |
|||
expect(fixture.componentInstance).toBeTruthy(); |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
如果你看一下导入内容, 你会注意到我们已经准备了一些测试模块来取代内置的ABP模块. 这对于模拟某些特性是必要的, 否则这些特性会破坏你的测试. 请记住**使用测试模块**并**调用其`withConfig`静态方法**. |
|||
|
|||
## 提示 |
|||
|
|||
### Angular测试库 |
|||
|
|||
虽然你可以使用Angular TestBed测试代码, 但你可以找到一个好的替代品[Angular测试库](https://testing-library.com/docs/angular-testing-library/intro). |
|||
|
|||
上面的简单示例可以用Angular测试库编写, 如下所示: |
|||
|
|||
```js |
|||
import { CoreTestingModule } from "@abp/ng.core/testing"; |
|||
import { ThemeBasicTestingModule } from "@abp/ng.theme.basic/testing"; |
|||
import { ThemeSharedTestingModule } from "@abp/ng.theme.shared/testing"; |
|||
import { ComponentFixture } from "@angular/core/testing"; |
|||
import { NgxValidateCoreModule } from "@ngx-validate/core"; |
|||
import { render } from "@testing-library/angular"; |
|||
import { MyComponent } from "./my.component"; |
|||
|
|||
describe("MyComponent", () => { |
|||
let fixture: ComponentFixture<MyComponent>; |
|||
|
|||
beforeEach(async () => { |
|||
const result = await render(MyComponent, { |
|||
imports: [ |
|||
CoreTestingModule.withConfig(), |
|||
ThemeSharedTestingModule.withConfig(), |
|||
ThemeBasicTestingModule.withConfig(), |
|||
NgxValidateCoreModule, |
|||
], |
|||
providers: [ |
|||
/* mock providers here */ |
|||
], |
|||
}); |
|||
|
|||
fixture = result.fixture; |
|||
}); |
|||
|
|||
it("should be initiated", () => { |
|||
expect(fixture.componentInstance).toBeTruthy(); |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
正如你所见, 二者非常相似. 当我们使用查询和触发事件时, 真正的区别就显现出来了. |
|||
|
|||
```js |
|||
// other imports |
|||
import { getByLabelText, screen } from "@testing-library/angular"; |
|||
import userEvent from "@testing-library/user-event"; |
|||
|
|||
describe("MyComponent", () => { |
|||
beforeEach(/* removed for sake of brevity */); |
|||
|
|||
it("should display advanced filters", () => { |
|||
const filters = screen.getByTestId("author-filters"); |
|||
const nameInput = getByLabelText(filters, /name/i) as HTMLInputElement; |
|||
expect(nameInput.offsetWidth).toBe(0); |
|||
|
|||
const advancedFiltersBtn = screen.getByRole("link", { name: /advanced/i }); |
|||
userEvent.click(advancedFiltersBtn); |
|||
|
|||
expect(nameInput.offsetWidth).toBeGreaterThan(0); |
|||
|
|||
userEvent.type(nameInput, "fooo{backspace}"); |
|||
expect(nameInput.value).toBe("foo"); |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
**Angular测试库中的查询遵循可维护测试**, 用户事件库提供了与DOM的**类人交互**, 并且该库通常有**清晰的API**简化组件测试. 下面提供一些有用的链接: |
|||
|
|||
- [查询](https://testing-library.com/docs/dom-testing-library/api-queries) |
|||
- [用户事件](https://testing-library.com/docs/ecosystem-user-event) |
|||
- [范例](https://github.com/testing-library/angular-testing-library/tree/main/apps/example-app/src/app/examples) |
|||
|
|||
### 在每个Spec之后清除DOM |
|||
|
|||
需要记住的一点是, Karma在真实的浏览器实例中运行测试. 这意味着, 你将能够看到测试代码的结果, 但也会遇到与文档正文连接的组件的问题, 这些组件可能无法在每次测试后都清除, 即使你配置了Karma也一样无法清除. |
|||
|
|||
我们准备了一个简单的函数, 可以在每次测试后清除所有剩余的DOM元素. |
|||
|
|||
```js |
|||
// other imports |
|||
import { clearPage } from "@abp/ng.core/testing"; |
|||
|
|||
describe("MyComponent", () => { |
|||
let fixture: ComponentFixture<MyComponent>; |
|||
|
|||
afterEach(() => clearPage(fixture)); |
|||
|
|||
beforeEach(async () => { |
|||
const result = await render(MyComponent, { |
|||
/* removed for sake of brevity */ |
|||
}); |
|||
fixture = result.fixture; |
|||
}); |
|||
|
|||
// specs here |
|||
}); |
|||
``` |
|||
|
|||
请确保你使用它, 否则Karma将无法删除对话框, 并且你将有多个模态对话框、确认框等的副本. |
|||
|
|||
### 等待 |
|||
|
|||
一些组件, 特别是在检测周期之外工作的模态对话框. 换句话说, 你无法在打开这些组件后立即访问这些组件插入的DOM元素. 同样, 插入的元素在关闭时也不会立即销毁. |
|||
|
|||
为此, 我们准备了一个`wait`函数. |
|||
|
|||
```js |
|||
// other imports |
|||
import { wait } from "@abp/ng.core/testing"; |
|||
|
|||
describe("MyComponent", () => { |
|||
beforeEach(/* removed for sake of brevity */); |
|||
|
|||
it("should open a modal", async () => { |
|||
const openModalBtn = screen.getByRole("button", { name: "Open Modal" }); |
|||
userEvent.click(openModalBtn); |
|||
|
|||
await wait(fixture); |
|||
|
|||
const modal = screen.getByRole("dialog"); |
|||
|
|||
expect(modal).toBeTruthy(); |
|||
|
|||
/* wait again after closing the modal */ |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
`wait`函数接受第二个参数, 即超时(默认值为`0`). 但是尽量不要使用它. 使用大于`0`的超时通常表明某些不正确事情发生了. |
|||
|
|||
## 测试示例 |
|||
|
|||
下面是一个测试示例. 它并没有涵盖所有内容, 但却能够对测试有一个更好的了解. |
|||
|
|||
```js |
|||
import { clearPage, CoreTestingModule, wait } from "@abp/ng.core/testing"; |
|||
import { ThemeBasicTestingModule } from "@abp/ng.theme.basic/testing"; |
|||
import { ThemeSharedTestingModule } from "@abp/ng.theme.shared/testing"; |
|||
import { ComponentFixture } from "@angular/core/testing"; |
|||
import { |
|||
NgbCollapseModule, |
|||
NgbDatepickerModule, |
|||
NgbDropdownModule, |
|||
} from "@ng-bootstrap/ng-bootstrap"; |
|||
import { NgxValidateCoreModule } from "@ngx-validate/core"; |
|||
import { CountryService } from "@proxy/countries"; |
|||
import { |
|||
findByText, |
|||
getByLabelText, |
|||
getByRole, |
|||
getByText, |
|||
queryByRole, |
|||
render, |
|||
screen, |
|||
} from "@testing-library/angular"; |
|||
import userEvent from "@testing-library/user-event"; |
|||
import { BehaviorSubject, of } from "rxjs"; |
|||
import { CountryComponent } from "./country.component"; |
|||
|
|||
const list$ = new BehaviorSubject({ |
|||
items: [{ id: "ID_US", name: "United States of America" }], |
|||
totalCount: 1, |
|||
}); |
|||
|
|||
describe("Country", () => { |
|||
let fixture: ComponentFixture<CountryComponent>; |
|||
|
|||
afterEach(() => clearPage(fixture)); |
|||
|
|||
beforeEach(async () => { |
|||
const result = await render(CountryComponent, { |
|||
imports: [ |
|||
CoreTestingModule.withConfig(), |
|||
ThemeSharedTestingModule.withConfig(), |
|||
ThemeBasicTestingModule.withConfig(), |
|||
NgxValidateCoreModule, |
|||
NgbCollapseModule, |
|||
NgbDatepickerModule, |
|||
NgbDropdownModule, |
|||
], |
|||
providers: [ |
|||
{ |
|||
provide: CountryService, |
|||
useValue: { |
|||
getList: () => list$, |
|||
}, |
|||
}, |
|||
], |
|||
}); |
|||
|
|||
fixture = result.fixture; |
|||
}); |
|||
|
|||
it("should display advanced filters", () => { |
|||
const filters = screen.getByTestId("country-filters"); |
|||
const nameInput = getByLabelText(filters, /name/i) as HTMLInputElement; |
|||
expect(nameInput.offsetWidth).toBe(0); |
|||
|
|||
const advancedFiltersBtn = screen.getByRole("link", { name: /advanced/i }); |
|||
userEvent.click(advancedFiltersBtn); |
|||
|
|||
expect(nameInput.offsetWidth).toBeGreaterThan(0); |
|||
|
|||
userEvent.type(nameInput, "fooo{backspace}"); |
|||
expect(nameInput.value).toBe("foo"); |
|||
|
|||
userEvent.click(advancedFiltersBtn); |
|||
expect(nameInput.offsetWidth).toBe(0); |
|||
}); |
|||
|
|||
it("should have a heading", () => { |
|||
const heading = screen.getByRole("heading", { name: "Countries" }); |
|||
expect(heading).toBeTruthy(); |
|||
}); |
|||
|
|||
it("should render list in table", async () => { |
|||
const table = await screen.findByTestId("country-table"); |
|||
|
|||
const name = getByText(table, "United States of America"); |
|||
expect(name).toBeTruthy(); |
|||
}); |
|||
|
|||
it("should display edit modal", async () => { |
|||
const actionsBtn = screen.queryByRole("button", { name: /actions/i }); |
|||
userEvent.click(actionsBtn); |
|||
|
|||
const editBtn = screen.getByRole("button", { name: /edit/i }); |
|||
userEvent.click(editBtn); |
|||
|
|||
await wait(fixture); |
|||
|
|||
const modal = screen.getByRole("dialog"); |
|||
const modalHeading = queryByRole(modal, "heading", { name: /edit/i }); |
|||
expect(modalHeading).toBeTruthy(); |
|||
|
|||
const closeBtn = getByText(modal, "×"); |
|||
userEvent.click(closeBtn); |
|||
|
|||
await wait(fixture); |
|||
|
|||
expect(screen.queryByRole("dialog")).toBeFalsy(); |
|||
}); |
|||
|
|||
it("should display create modal", async () => { |
|||
const newBtn = screen.getByRole("button", { name: /new/i }); |
|||
userEvent.click(newBtn); |
|||
|
|||
await wait(fixture); |
|||
|
|||
const modal = screen.getByRole("dialog"); |
|||
const modalHeading = queryByRole(modal, "heading", { name: /new/i }); |
|||
|
|||
expect(modalHeading).toBeTruthy(); |
|||
}); |
|||
|
|||
it("should validate required name field", async () => { |
|||
const newBtn = screen.getByRole("button", { name: /new/i }); |
|||
userEvent.click(newBtn); |
|||
|
|||
await wait(fixture); |
|||
|
|||
const modal = screen.getByRole("dialog"); |
|||
const nameInput = getByRole(modal, "textbox", { |
|||
name: /^name/i, |
|||
}) as HTMLInputElement; |
|||
|
|||
userEvent.type(nameInput, "x"); |
|||
userEvent.type(nameInput, "{backspace}"); |
|||
|
|||
const nameError = await findByText(modal, /required/i); |
|||
expect(nameError).toBeTruthy(); |
|||
}); |
|||
|
|||
it("should delete a country", () => { |
|||
const getSpy = spyOn(fixture.componentInstance.list, "get"); |
|||
const deleteSpy = jasmine.createSpy().and.returnValue(of(null)); |
|||
fixture.componentInstance.service.delete = deleteSpy; |
|||
|
|||
const actionsBtn = screen.queryByRole("button", { name: /actions/i }); |
|||
userEvent.click(actionsBtn); |
|||
|
|||
const deleteBtn = screen.getByRole("button", { name: /delete/i }); |
|||
userEvent.click(deleteBtn); |
|||
|
|||
const confirmText = screen.getByText("AreYouSure"); |
|||
expect(confirmText).toBeTruthy(); |
|||
|
|||
const confirmBtn = screen.getByRole("button", { name: "Yes" }); |
|||
userEvent.click(confirmBtn); |
|||
|
|||
expect(deleteSpy).toHaveBeenCalledWith(list$.value.items[0].id); |
|||
expect(getSpy).toHaveBeenCalledTimes(1); |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
## CI配置 |
|||
|
|||
你的CI环境需要不同的配置. 要为单元测试设置新的配置, 请在测试项目中找到 _angular.json_ 文件, 或者如下所示添加一个: |
|||
|
|||
```json |
|||
// angular.json |
|||
|
|||
"test": { |
|||
"builder": "@angular-devkit/build-angular:karma", |
|||
"options": { /* several options here */ }, |
|||
"configurations": { |
|||
"production": { |
|||
"karmaConfig": "karma.conf.prod.js" |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
现在你可以复制 _karma.conf.js_ 作为 _karma.conf.prod.js_ 并在其中使用你喜欢的任何配置. 请查看[Karma配置文档](http://karma-runner.github.io/5.2/config/configuration-file.html)配置选项. |
|||
|
|||
最后, 不要忘记使用以下命令运行CI测试: |
|||
|
|||
```sh |
|||
npm test -- --prod |
|||
``` |
|||
|
|||
## 另请参阅 |
|||
|
|||
- [ABP Community Video - Unit Testing with the Angular UI](https://community.abp.io/articles/unit-testing-with-the-angular-ui-p4l550q3) |
|||
@ -0,0 +1,220 @@ |
|||
# ASP.NET Core MVC / Razor Pages: 测试 |
|||
|
|||
> 你可以参考[ASP.NET Core集成测试文档](https://docs.microsoft.com/en-us/aspnet/core/test/integration-tests)了解ASP.NET Core集成测试的详细内容. 本文档解释了ABP框架提供的附加测试基础设施. |
|||
|
|||
## 应用程序启动模板 |
|||
|
|||
应用程序启动模板的`.Web`项目其中包含应用程序的UI视图/页面/组件, 并提供`.Web.Tests`项目来测试这些内容. |
|||
|
|||
 |
|||
|
|||
## 测试Razor页面 |
|||
|
|||
假设你已经创建了一个名为`Issues.cshtml`的Razor页面, 包含以下内容; |
|||
|
|||
**Issues.cshtml.cs** |
|||
|
|||
````csharp |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Mvc.RazorPages; |
|||
using MyProject.Issues; |
|||
|
|||
namespace MyProject.Web.Pages |
|||
{ |
|||
public class IssuesModel : PageModel |
|||
{ |
|||
public List<IssueDto> Issues { get; set; } |
|||
|
|||
private readonly IIssueAppService _issueAppService; |
|||
|
|||
public IssuesModel(IIssueAppService issueAppService) |
|||
{ |
|||
_issueAppService = issueAppService; |
|||
} |
|||
|
|||
public async Task OnGetAsync() |
|||
{ |
|||
Issues = await _issueAppService.GetListAsync(); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
**Issues.cshtml** |
|||
|
|||
````html |
|||
@page |
|||
@model MyProject.Web.Pages.IssuesModel |
|||
<h2>Issue List</h2> |
|||
<table id="IssueTable" class="table"> |
|||
<thead> |
|||
<tr> |
|||
<th>Issue</th> |
|||
<th>Closed?</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
@foreach (var issue in Model.Issues) |
|||
{ |
|||
<tr> |
|||
<td>@issue.Title</td> |
|||
<td> |
|||
@if (issue.IsClosed) |
|||
{ |
|||
<span>Closed</span> |
|||
} |
|||
else |
|||
{ |
|||
<span>Open</span> |
|||
} |
|||
</td> |
|||
</tr> |
|||
} |
|||
</tbody> |
|||
</table> |
|||
```` |
|||
|
|||
本页仅创建一个包含issue的表格: |
|||
|
|||
 |
|||
|
|||
你可以在`.Web.Tests`项目中编写一个测试类如下所示: |
|||
|
|||
````csharp |
|||
using System.Threading.Tasks; |
|||
using HtmlAgilityPack; |
|||
using Shouldly; |
|||
using Xunit; |
|||
|
|||
namespace MyProject.Pages |
|||
{ |
|||
public class Issues_Tests : MyProjectWebTestBase |
|||
{ |
|||
[Fact] |
|||
public async Task Should_Get_Table_Of_Issues() |
|||
{ |
|||
// Act |
|||
|
|||
var response = await GetResponseAsStringAsync("/Issues"); |
|||
|
|||
//Assert |
|||
|
|||
var htmlDocument = new HtmlDocument(); |
|||
htmlDocument.LoadHtml(response); |
|||
|
|||
var tableElement = htmlDocument.GetElementbyId("IssueTable"); |
|||
tableElement.ShouldNotBeNull(); |
|||
|
|||
var trNodes = tableElement.SelectNodes("//tbody/tr"); |
|||
trNodes.Count.ShouldBeGreaterThan(0); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
`GetResponseAsStringAsync`是一个快捷方法, 它来自执行HTTP GET请求的基类, 检查生成的HTTP状态是否为`200`, 并将响应作为`string`返回. |
|||
|
|||
> 你可以使用`Client`对象(类型为`HttpClient`)对服务器执行任何类型的请求, 并读取响应.`GetResponseAsStringAsync`只是一种快捷方法. |
|||
|
|||
本例使用[HtmlAgilityPack](https://html-agility-pack.net/)库来解析传入的HTML并测试它是否包含issue表格. |
|||
|
|||
> 本例假设的数据库中存在一些初始issue. 请参阅[测试文档](../../Testing.md)的*种子数据*部分, 了解如何设置种子数据, 以便可以假定数据库中有一些可用的初始数据. |
|||
|
|||
## 控制器测试 |
|||
|
|||
测试控制器也不例外. 只需使用正确的URL向服务器执行请求, 获取响应并做出断言. |
|||
|
|||
### 查看结果 |
|||
|
|||
如果控制器返回一个视图, 你可以使用类似的代码来测试返回的HTML. 参见上面的Razor页面示例. |
|||
|
|||
### 对象结果 |
|||
|
|||
如果控制器返回对象结果, 则可以使用`GetResponseAsObjectAsync`方法. |
|||
|
|||
假设你有一个如下定义的控制器: |
|||
|
|||
````csharp |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Mvc; |
|||
using MyProject.Issues; |
|||
using Volo.Abp.AspNetCore.Mvc; |
|||
|
|||
namespace MyProject.Web.Controllers |
|||
{ |
|||
[Route("api/issues")] |
|||
public class IssueController : AbpController |
|||
{ |
|||
private readonly IIssueAppService _issueAppService; |
|||
|
|||
public IssueController(IIssueAppService issueAppService) |
|||
{ |
|||
_issueAppService = issueAppService; |
|||
} |
|||
|
|||
[HttpGet] |
|||
public async Task<List<IssueDto>> GetAsync() |
|||
{ |
|||
return await _issueAppService.GetListAsync(); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
你可以编写测试代码来调用API并获得结果: |
|||
|
|||
````csharp |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using MyProject.Issues; |
|||
using Shouldly; |
|||
using Xunit; |
|||
|
|||
namespace MyProject.Pages |
|||
{ |
|||
public class Issues_Tests : MyProjectWebTestBase |
|||
{ |
|||
[Fact] |
|||
public async Task Should_Get_Issues_From_Api() |
|||
{ |
|||
var issues = await GetResponseAsObjectAsync<List<IssueDto>>("/api/issues"); |
|||
|
|||
issues.ShouldNotBeNull(); |
|||
issues.Count.ShouldBeGreaterThan(0); |
|||
} |
|||
} |
|||
} |
|||
```` |
|||
|
|||
## 测试JavaScript代码 |
|||
|
|||
ABP框架不提供任何基础设施来测试JavaScript代码. 你可以使用任何测试框架和工具来测试JavaScript代码. |
|||
|
|||
## 测试基础设施 |
|||
|
|||
[Volo.Abp.AspNetCore.TestBase](https://www.nuget.org/packages/Volo.Abp.AspNetCore.TestBase) 提供了集成到ABP框架和ASP.NET Core的测试基础设施. |
|||
|
|||
> Volo.Abp.AspNetCore.TestBase 已经安装在 `.Web.Tests` 项目中. |
|||
|
|||
此包提供的`AbpAspNetCoreIntegratedTestBase`作为派生测试类的基类. 上面使用的`MyProjectWebTestBase`继承自`AbpAspNetCoreIntegratedTestBase`, 因此我们间接继承了`AbpAspNetCoreIntegratedTestBase`. |
|||
|
|||
### 基本属性 |
|||
|
|||
`AbpAspNetCoreIntegratedTestBase` 提供了测试中使用的以下基本属性: |
|||
|
|||
* `Server`: 在测试中托管web应用程序的`TestServer`实例. |
|||
* `Client`: 为执行对测试服务器的请求配置`HttpClient`实例. |
|||
* `ServiceProvider`: 可以在你需要时处理服务提供服务. |
|||
|
|||
### 基本方法 |
|||
|
|||
`AbpAspNetCoreIntegratedTestBase` 提供了以下方法, 如果需要自定义测试服务器, 可以重写这些方法: |
|||
|
|||
* `ConfigureServices` 仅为派生测试类注册/替换服务时可以重写使用. |
|||
* `CreateHostBuilder` 可用于自定义生成 `IHostBuilder`. |
|||
|
|||
另请参阅 |
|||
|
|||
* [总览/服务器端测试](../../Testing.md) |
|||
@ -1,8 +1,8 @@ |
|||
using System.Threading.Tasks; |
|||
|
|||
// ReSharper disable once CheckNamespace (Keeping for backward compability)
|
|||
namespace Volo.Abp.EventBus; |
|||
|
|||
//TODO: Move to the right namespace in v3.0
|
|||
public interface ILocalEventHandler<in TEvent> : IEventHandler |
|||
{ |
|||
/// <summary>
|
|||
@ -0,0 +1,3 @@ |
|||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd"> |
|||
<ConfigureAwait ContinueOnCapturedContext="false"/> |
|||
</Weavers> |
|||
@ -0,0 +1,30 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> |
|||
<!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuilt. --> |
|||
<xs:element name="Weavers"> |
|||
<xs:complexType> |
|||
<xs:all> |
|||
<xs:element name="ConfigureAwait" minOccurs="0" maxOccurs="1"> |
|||
<xs:complexType> |
|||
<xs:attribute name="ContinueOnCapturedContext" type="xs:boolean" /> |
|||
</xs:complexType> |
|||
</xs:element> |
|||
</xs:all> |
|||
<xs:attribute name="VerifyAssembly" type="xs:boolean"> |
|||
<xs:annotation> |
|||
<xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation> |
|||
</xs:annotation> |
|||
</xs:attribute> |
|||
<xs:attribute name="VerifyIgnoreCodes" type="xs:string"> |
|||
<xs:annotation> |
|||
<xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation> |
|||
</xs:annotation> |
|||
</xs:attribute> |
|||
<xs:attribute name="GenerateXsd" type="xs:boolean"> |
|||
<xs:annotation> |
|||
<xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation> |
|||
</xs:annotation> |
|||
</xs:attribute> |
|||
</xs:complexType> |
|||
</xs:element> |
|||
</xs:schema> |
|||
@ -0,0 +1,15 @@ |
|||
<Project Sdk="Microsoft.NET.Sdk"> |
|||
|
|||
<Import Project="..\..\..\configureawait.props" /> |
|||
<Import Project="..\..\..\common.props" /> |
|||
|
|||
<PropertyGroup> |
|||
<TargetFramework>netstandard2.0</TargetFramework> |
|||
<RootNamespace /> |
|||
</PropertyGroup> |
|||
|
|||
<ItemGroup> |
|||
<ProjectReference Include="..\Volo.Abp.Core\Volo.Abp.Core.csproj" /> |
|||
</ItemGroup> |
|||
|
|||
</Project> |
|||
@ -0,0 +1,7 @@ |
|||
using Volo.Abp.Modularity; |
|||
|
|||
namespace Volo.Abp.Gdpr; |
|||
|
|||
public class AbpGdprAbstractionsModule : AbpModule |
|||
{ |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
|
|||
namespace Volo.Abp.Gdpr; |
|||
|
|||
[Serializable] |
|||
public class GdprDataInfo : Dictionary<string, string> |
|||
{ |
|||
|
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
using System; |
|||
|
|||
namespace Volo.Abp.Gdpr; |
|||
|
|||
[Serializable] |
|||
public class GdprUserDataDeletionRequestedEto |
|||
{ |
|||
public Guid UserId { get; set; } |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
using System; |
|||
|
|||
namespace Volo.Abp.Gdpr; |
|||
|
|||
[Serializable] |
|||
public class GdprUserDataPreparedEto |
|||
{ |
|||
public Guid RequestId { get; set; } |
|||
|
|||
public string Provider { get; set; } |
|||
|
|||
public GdprDataInfo Data { get; set; } |
|||
} |
|||
@ -0,0 +1,8 @@ |
|||
using System; |
|||
|
|||
namespace Volo.Abp.Gdpr; |
|||
|
|||
public class GdprUserDataProviderContext |
|||
{ |
|||
public Guid UserId { get; set; } |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
using System; |
|||
|
|||
namespace Volo.Abp.Gdpr; |
|||
|
|||
[Serializable] |
|||
public class GdprUserDataRequestedEto |
|||
{ |
|||
public Guid UserId { get; set; } |
|||
|
|||
public Guid RequestId { get; set; } |
|||
} |
|||
@ -0,0 +1,4 @@ |
|||
@if (ViewBag.CanonicalUrl != null) |
|||
{ |
|||
<link rel="canonical" href="@ViewBag.CanonicalUrl" /> |
|||
} |
|||
@ -0,0 +1,15 @@ |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Mvc; |
|||
using Volo.Abp.AspNetCore.Mvc; |
|||
using Volo.Abp.DependencyInjection; |
|||
|
|||
namespace Volo.Docs.Pages.Shared.Components.Head |
|||
{ |
|||
public class HeadViewComponent : AbpViewComponent |
|||
{ |
|||
public virtual IViewComponentResult Invoke() |
|||
{ |
|||
return View("/Pages/Shared/Components/Head/Default.cshtml"); |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue