2024年2月

对于在线客服与营销系统,客服端指的是后台提供服务的客服或营销人员,他们使用客服程序在后台观察网站的被访情况,开展营销活动或提供客户服务。在本篇文章中,我将详细介绍如何通过 WPF + Chrome 内核的方式实现复合客服端应用程序。

先看实现效果

客服程序界面中的 聊天记录部分、文字输入框部分 使用的是基于 Chrome 内核的 WebView2 进行呈现的。

客服端

访客端

视频实拍:演示升讯威在线客服系统在网络中断,直接禁用网卡,拔掉网线的情况下,也不丢消息,不出异常。

https://blog.shengxunwei.com/Home/Post/fe432a51-337c-4558-b9e8-347b58cbcd53


要实现这样的效果只需三个步骤

  • 嵌入组件
  • 响应事件
  • 调用 JavaScript 函数

1. 嵌入组件

首先使用 NuGet 将 WebView2 SDK 添加到项目中,然后将 WebView 添加窗口界面。

<Window x:Class="WPF_Getting_Started.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:{YOUR PROJECT NAME}"
        xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
        mc:Ignorable="d"
        Title="MainWindow"
        Height="450"
        Width="800"
>
    <Grid>
     <DockPanel>
     <wv2:WebView2 Name="webView"
                  Source="https://www.microsoft.com"
     />
    </DockPanel>
    </Grid>
</Window>

2. 响应事件

在网页导航期间,WebView2 控件将引发事件。 承载 WebView2 控件的应用侦听以下事件。

  • NavigationStarting
  • SourceChanged
  • ContentLoading
  • HistoryChanged
  • NavigationCompleted

例:修改构造函数以匹配以下代码段并添加 EnsureHttps 函数。


public MainWindow()
{
    InitializeComponent();
    webView.NavigationStarting += EnsureHttps;
}

void EnsureHttps(object sender, CoreWebView2NavigationStartingEventArgs args)
{
    String uri = args.Uri;
    if (!uri.StartsWith("https://"))
    {
        args.Cancel = true;
    }
}

3. 调用 JavaScript 函数

可以在运行时使用主机应用将 JavaScript 代码注入控件。 可以运行任意 JavaScript 或添加初始化脚本。 在删除 JavaScript 之前,注入的 JavaScript 适用于所有新的顶级文档和任何子框架。

例如,添加在用户导航到非 HTTPS 网站时发送警报的脚本。 修改 EnsureHttps 函数以将脚本注入到使用 ExecuteScriptAsync 方法的 Web 内容中。


void EnsureHttps(object sender, CoreWebView2NavigationStartingEventArgs args)
{
    String uri = args.Uri;
    if (!uri.StartsWith("https://"))
    {
        webView.CoreWebView2.ExecuteScriptAsync($"alert('{uri} is not safe, try an https link')");
        args.Cancel = true;
    }
}

完成

只需要以上简单三个步骤,嵌入组件、响应事件、调用 JavaScript 函数。就可以完成 WPF + Chrome 内核 的复合式应用程序!

自 ChatGPT 问世以来,AI的风口就来了。

AI是一门研究如何使计算机具有类似人类智能的学科。

自从ChatGPT-3.5给大家带来了极大的震惊之后,全民都在谈论AI,在这个AI大时代背景之下,如果你想进一步了解AI相关热词含义,从而更好的理解当下AI的基础原理,本文就不容错过。

如今,当你找专业人士解释一些关于AI的基础概念,最大的问题就是,你也许只是想简单的了解一个热词的简单解释,回答者跟你解释时,却引入了更多你不熟悉的新概念。

当你不得不追问这些新概念的含义时,却发现又引入了一堆新词,此刻是不是感觉头都大了?其实这么多新词和概念也很难通过一次简短的询问来搞懂并厘清期间的关系。

如果你也有这样的困惑,无论是提问方还是解答方,都可以利用这篇文章来帮助自己理解或辅助回答。

下面我们就从最熟悉的ChatGPT切入提问,看看都有哪些AI相关高频词汇,又各自是什么意思。

ChatGPT 是什么?

ChatGPT是一种LLM(大语言模型),具体是由OpenAI开发的一种聊天型生成预训练模型。它基于GPT架构,专门设计用于处理自然语言对话和生成有意义的回应。

LLM(大语言模型)是什么?

LLM英文全称是:Large Language Model。

大语言模型通常是指参数规模庞大、在大规模语料库上进行训练的自然语言处理模型。

另外LLM也不止OpenAI的GPT一种,还有其他很多家,比如Meta的Llama 2,以及更专注于企业应用的Cohere等。

OpenAI 是什么?

OpenAI是一个人工智能研究实验室,致力于推动人工智能的发展。

OpenAI 是许多先进语言模型的背后力量,其中最著名的就是 GPT 系列。

GPT 是什么?

GPT 全称是 "Generative Pre-trained Transformer",翻译成中文是"生成式预训练转换器"。

GPT 是 OpenAI 提出的一系列预训练语言模型,它采用了 Transformer 架构。这些模型在大规模文本数据上进行预训练,学习了丰富的语言知识,可以用于各种自然语言处理任务。

Transformer 是什么?

Transformer 是一种深度学习架构,被广泛用于处理序列数据,比如自然语言处理(NLP)。

它的自注意力机制使其在处理长距离依赖和捕捉序列中的上下文关系方面非常强大。

GPT 就是基于 Transformer 架构构建的。Transformer 架构的主要组成部分包括编码器(Encoder)和解码器(Decoder)。

Encoder 和 Decoder 怎么理解?

编码器(Encoder)负责将输入序列映射为高维度的向量表示。

解码器(Decoder)用于生成目标序列。

向量(Vector)又是什么?

为了让计算机理解和处理文本,我们需要将离散的令牌转换为连续的表示形式。这时,就引入了向量的概念。

向量是一种包含数值的数组,它能够表示令牌的语义信息。每个维度都承载着特定的语义或信息,使得计算机能够更好地理解文本。

令牌(Token)又是个啥?

在自然语言处理中,我们将文本划分为基本单元,这些基本单元称为令牌。

令牌可以是单词、字符或其他离散的文本单位。在处理文本数据时,我们通常将它们作为模型的输入。

每个令牌代表着文本的一个部分,是构建语言模型的基础。

那令牌如何能够向量化呢?

为了将离散的令牌映射到连续的向量表示,我们使用了嵌入技术。

嵌入(Embedding)是一种将高维度、离散的数据映射到低维度、连续空间的方法。

在自然语言处理中,词嵌入(Word Embedding)是常见的嵌入技术,它将单词映射为密集的向量,捕捉了单词之间的语义关系。

向量化数据存在哪里?

向量化数据建议存储在数据库中,这样能够允许高效的检索和查询。

向量可以作为数据库中的一个字段进行存储,或者存储在专门设计的向量数据库中。

企业AI应用中被高频提到的RAG是个啥?

RAG 全称是 "Retrieval-Augmented Generation",表示一种检索增强生成的方法。

听完这样的解释是不是更懵了?咋就增强了?

好吧,说人话,RAG 模型就是结合了上面我们提过的LLM 和 外部知识库,以实现生成过程的增强和优化。

啥叫外部知识库?为啥如此强大的LLM要结合外部知识库呢?

外部知识库就是LLM在训练过程中未直接学习到的、特定领域或任务的额外信息。

比如企业自己的私域知识,例如某保险公司新推出的一些具体保险产品详情,通用LLM训练时肯定就不可能有这样的知识。

又比如一些特定领域的专业知识,诸如医学数据库、法律文件、科学论文等,通用LLM训练时未完全涵盖或深入理解这些领域。

通过结合这样的外部知识库,RAG 模型能够在生成过程中利用这些额外的知识和上下文,使其在特定企业AI应用中能够更为灵活和强大。

最后,大家觉得ChatGPT到底有没有自己的思想?

笔者认为在理解上述基本原理概念之后,会发现在这个机制下,尽管ChatGPT可以生成看起来像是回应问题或进行对话的文本,让人误以为它有思想,但它其实并不具备深层次的理解、意识或主观性。

不过这也是基于当前的ChatGPT,随着AI技术不断发展未来还真不好说,之前不是就因为担忧AI未来的安全问题,还引发了那场OpenAI的CEO被罢免的事件吗,大家怎么看呢?

随着 VisualStudio17.9预览版3的发布,我们为代码搜索(也称为 All-In-One Search)带来了一些令人兴奋的增强。自从我们上次更新搜索体验以来,我们一直在努力改进体验,并想出增加体验的方法。现在,您可以在解决方案中搜索任何单词或字符串,补充来自代码库的文件和符号结果。现在可以在代码库中搜索局部变量名、注释中的单词、参数名或任何其他字符串。

如果你正在寻找一个更专业的文本搜索体验,在文件中查找(Ctrl+Shift+F)和快速查找(Ctrl+F)有更多的选项和功能。

我在哪里可以找到这些?

默认的代码搜索体验(Ctrl+T 或 Ctrl+,)将在适用时包含精确的文本匹配,包括以前未包含的注释和局部变量等项。与文件、类型和成员相比,文本结果的优先级是低的,所以它不应该干扰您当前的工作流程。

对于纯文本视图,您可以通过执行以下操作之一过滤到仅文本结果:

    • 点击搜索下方的“text (x:)”按钮

    • 以“x:”作为搜索查询的前缀

    • 使用快捷键“Shift+Alt+F”

    • 进入菜单选项“Edit > Go To > Go To Text”

在纯文本体验中,还可以通过搜索栏最右边的按钮切换“Match case(匹配大小写)”、“Match whole word(匹配整个单词)”和“Use regular expressions(使用正则表达式)”。

今天就试试吧,让我们知道您的想法

从17.9预览2开始,代码搜索的全文支持默认在预览通道中可用。(编辑:我们发现该功能可能不会在预览频道中默认完全推出,但您仍然可以通过“Tools >  Options >  Environment >  Preview Features >  Plain text search in All-In-One Search”来启用)。

它也将在17.9版本的主版本中作为预览功能提供,您可以通过“Tools >  Options >  Environment >  Preview Features >  Plain text search in All-In-One Search“来启用。

小结

我们感谢您花时间报告问题/建议,并希望您在使用 Visual Studio 时继续给我们反馈,告诉我们您喜欢什么以及我们可以改进什么。您的反馈对于帮助我们使 Visual Studio 成为最好的工具至关重要!您可以通过 开发者社区与我们分享反馈:通过报告错误或问题和分享您对新功能或现有功能改进的建议。

通过在 YouTube, Twitter, LinkedIn, Twitch 和 Microsoft Learn 上关注我们与 Visual Studio 团队保持联系。

原文链接:https://devblogs.microsoft.com/visualstudio/17-9-preview-3-brings-exciting-changes-to-code-search/

本文分享自华为云社区《
JavaChassis3技术解密:易扩展的多种注册中心支持
》,作者:liubao68。

Java Chassis 的早期版本依赖于 Service Center,提供了很多差异化的竞争力:

  • 接口级别转发。 通过注册中心管理微服务的每个版本的元数据,特别是契约数据。 结合契约数据,能够实现版本级别的路由能力。 比如一个微服务存在 v1 和 v2 两个版本, 其中 v1 版本存在接口 op1, op2, v2 版本存在接口 op1, op2, op3, 在灰度场景, Java Chassis能够自动将 op3 的访问转发到 v2 版本,将 op1, op2 的访问在 v1, v2版本做负载均衡。
  • 基于 version-rule 的实例选择。 客户端能够配置 version-rule, 比如 last, 2.0+等。 这样客户端能够根据实际情况,筛选实例的版本。

Java Chassis过度依赖 Service Center, 为产品的发展带来了一些瓶颈。 Java Chassis的生态推广依赖于 Service Center的生态推广, 不利于Java Chassis被更多用户使用。 随着云的发展, 越来越多的客户也期望一套代码,能够在不同的云环境运行,有些云产商未提供Service Center运行环境,那么用户选择Java Chassis 就会存在顾虑。

基于上述原因, Java Chassis简化了注册发现的依赖,定义了简单容易实现的接口,并基于
Nacos
提供了实现,未来还会提供
zookeeper
等实现。 Java Chassis 采用了一系列新的设计模式, 保证了在降低注册中心功能依赖的前提下,不降低应用自身的可靠性。

接口级别转发的替代方案

依赖于 Service Center, Java Chassis提供了接口级别转发。 Java Chassis 3 首先做的一个变化是删除了对于接口级别转发的支持。 这样对于注册中心的依赖复杂度至少可以降低 70%。 然而灰度场景依然对很多业务比较重要, Java Chassis 3使用灰度发布解决这个问题。 使用灰度发布的好处是不用依赖注册中心提供版本元数据管理能力,只需要每个实例具备版本号等简单元数据信息。

servicecomb:
# enable router
foredge service
router:
type: router
routeRule:
business:
| - precedence: 2match:
apiPath:
prefix:
"/business/v2"route:- weight: 100tags:
version:
2.0.0 - precedence: 1match:
apiPath:
prefix:
"/business/v1/dec"route:- weight: 50tags:
version:
1.1.0 - weight: 50tags:
version:
2.0.0

注册发现接口及其实现

Java Chassis 3 只需要使用
Discovery
接口就能够提供新的注册发现支持。 Java Chassis会调用
findServiceInstances
查询实例,如果后续实例发生变更,注册中心实现通过
InstanceChangedListener
通知 Java Chassis.

/**
* This is the core service discovery interface. <br/>
*/ public interface Discovery<D extends DiscoveryInstance>extends SPIEnabled, SPIOrder, LifeCycle {interface InstanceChangedListener<D extends DiscoveryInstance>{/**
* Called by Discovery Implementations when instance list changed.
* @param registryName Name of the calling discovery implementation
* @param application Microservice application
* @param serviceName Microservice name
* @param updatedInstances The latest updated instances.
*/ void onInstanceChanged(String registryName, String application, String serviceName, List<D>updatedInstances);
}

String name();
/**
* If this implementation enabled for this microservice.
*/boolean enabled(String application, String serviceName);/**
* Find all instances.
*
* Life Cycle:This method is called anytime after <code>run</code>.
*
* @param application application
* @param serviceName microservice name
* @return all instances match the criteria.
*/List<D>findServiceInstances(String application, String serviceName);/**
* Discovery can call InstanceChangedListener when instance get changed.
*/ void setInstanceChangedListener(InstanceChangedListener<D>instanceChangedListener);
}

Java Chassis 3 通过
Registration
来管理注册, 注册过程分为
init

run

destroy
简单的生命周期, 可以在
init
准备注册的数据,
run
执行注册,
destroy
则在注册失败或者系统停止的时候执行。

/**
* This is the core service registration interface. <br/>
*/ public interface Registration<R extends RegistrationInstance>extends SPIEnabled, SPIOrder, LifeCycle {
String name();
/**
* get MicroserviceInstance </br>
*
* Life Cycle:This method is called anytime after <code>run</code>.
*/R getMicroserviceInstance();/**
* update MicroserviceInstance status </br>
*
* Life Cycle:This method is called anytime after <code>run</code>.
*/boolean updateMicroserviceInstanceStatus(MicroserviceInstanceStatus status);/**
* adding schemas to Microservice </br>
*
* Life Cycle:This method is called after <code>init</code> and before <code>run</code>.
*/ voidaddSchema(String schemaId, String content);/**
* adding endpoints to MicroserviceInstance </br>
*
* Life Cycle:This method is called after <code>init</code> and before <code>run</code>.
*/ voidaddEndpoint(String endpoint);/**
* adding property to MicroserviceInstance </br>
*
* Life Cycle:This method is called after <code>init</code> and before <code>run</code>.
*/ voidaddProperty(String key, String value);
}

注册发现的组合

Java Chassis 3可以独立实现多个
Discovery

Registration
, 达到向多个注册中心注册和从多个注册中心发现实例的作用。 每个实例根据实例ID唯一来标识。 如果实例ID相同, 会被认为是同一个实例, 如果不同, 则会认为是不同的实例。 在
Java Chassis 3技术解密:注册中心分区隔离
中聊到了, Java Chassis 要求每次实例注册(新的进程), 生成唯一的实例ID, 以解决注册分区隔离带来的实例假下线问题。
Discovery

Registration
都包含了 Java Chassis 定义的基础信息。

/**
* Standard information used for microservice instance registration and discovery.
*/ public interfaceMicroserviceInstance {/**
* Environment(Required): Used for logic separation of microservice instance. Only
* microservice instance with same environment can discovery each other.
*/String getEnvironment();/**
* Application(Required): Used for logic separation of microservice instance. Only
* microservice instance with same application can discovery each other.
*/String getApplication();/**
* Service Name(Required): Unique identifier for microservice.
*/String getServiceName();/**
* Service Name Alias(Optional): Unique identifier for microservice.
* This alias is used by registry implementation to support rename
* of a microservice, e.g. old consumers use old service name can
* find a renamed microservice service.
*/String getAlias();/**
* Service Version(Required): version of this microservice.
*/String getVersion();/**
* Data center info(Optional).
*/DataCenterInfo getDataCenterInfo();/**
* Service Description(Optional)
*/String getDescription();/**
* Service Properties(Optional)
*/Map<String, String>getProperties();/**
* Service Schemas(Optional): Open API information.
*/Map<String, String>getSchemas();/**
* Service endpoints(Optional).
*/List<String>getEndpoints();/**
* Microservice instance id(Required). This id can be generated when microservice instance is starting
* or assigned by registry implementation.
*
* When microservice instance is restarted, this id should be changed.
*/String getInstanceId();/**
* Microservice service id(Optional). This is used for service center, other implementations may not
* support service id.
*/ defaultString getServiceId() {return "";
}
}

在实现注册发现的时候,需要保证该接口定义的基础信息能够注册到注册中心,查询实例的时候,能够获取到这些信息。

客户故事:不把鸡蛋放到同一个篮子里面,是技术选型里面很重要的考量。解决方案的开放性和可替代性、云服务的可替代性,是很多客户都关注的问题。对于一个开源的技术框架,Java Chassis早期的版本虽然设计上也支持不同的注册中心扩展,但是实现难度很高,不自觉的把客户使用其他注册中心替换 service center的要求变得不可行。提供更加简化的注册发现实现,虽然减少了少量有有竞争力的功能特性,但是极大降低了客户选型的顾虑。

点击关注,第一时间了解华为云新鲜技术~

我们是
袋鼠云数栈 UED 团队
,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。

本文作者:佳岚

The more your tests resemble the way your software is used, the more confidence they can give you.
您的测试越接近软件的使用方式,它们就越能给您带来信心。

什么是 testing-library?

在了解
testing-library
前,我们可以看看使用原生方法是如何进行 React 组件测试的。

import Header from ".."
import client from 'react-dom/client'
import  { act } from 'react-dom/test-utils'

let container;
beforeEach(() => {
    container = document.createElement('div');
    document.body.appendChild(container)
});
afterEach(() => {
    document.body.removeChild(container);
    container = null;
})
test('test render', () => {
    act(() => {
        client.createRoot(container!).render(<Header />)
    });
    const button = container!.querySelector('button');
    const count = container!.querySelector("span[title='count']");

    act(() => {
        button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
        // or Simulate.click(button!)
    });
    expect(count?.textContent).toEqual('1')
})

在上面案例中,我们需要自行创建一个根容器来渲染 React 组件, 且我们必须及时清除该根容器,避免对其他测试用例产生影响。
在 Header 组件中,我们每次点击按钮都会进行计数+1,当我们要进行点击时,需要自行创建事件实例,且由于合成事件的原因需要增加
bubbles
属性,这使得对开发者也就有了些许能力要求。
除此之外,事件触发必须包裹在
act
方法中,
render
方法中相同。

什么是
act
?

正常情况下,我们的单测代码是同步执行的,即代码执行完毕则单测完成。React 中的渲染并不是同步的,当我们进行事件触发,导致了
rerender
后,并不能立马获取新的页面结果,导致后续的断言失败。
act
是由 React 官方在
react-dom/test-utils
中提供的一个方法,它能够让回调函数调用后,立即执行 React 内部 pending 中的异步队列。

我们再使用
RTL
去实现代码,
RTL
为我们简化了 render 所需要的重复模板代码,模拟事件也不再需要包裹
act
,并提供了通用的查询方法。

test('test by RTL', () => {
    const { getByRole, getByTitle } = render(<Header />);
    const button = getByRole("button")
    const count = getByTitle('count')
    fireEvent.click(button);
    expect(count.textContent).toEqual('1');
})

设计理念

testing-library 是
以用户为中心
的方式进行 UI 组件的测试。什么是
以用户为中心

即以用户的角度方式去审视你的 UI 组件,用户是不关心你的组件内部是如何实现的,只关心最终的功能效果是否正确。你不应该在 testing-library 中去测试组件的 props 与内部 state,生命周期等是否正确。
Why ?

组成架构

testing-library 的核心部分是
DOM Testing Library

@testing-library/dom
, 它提供了通用的DOM 查询功能,如
getByRole
getByText
与事件行为的基本实现。

在此基础上,再衍生出各自框架的专有包,如
React Testing Library

Vue Testing Library
,对于不同的前端框架,其使用方法是基本一致的, 提供不同实现方式的
render

fireEvent
方法。
除了每个前端框架提供各自的
fireEvent
接口外,还额外提供了一个
@testing-library/user-event
的通用包,不依赖于所选框架实现,它能够对用户事件的真实模拟,下文会详细说到。

除此之外,还针对
Jest
测试框架开发了一个断言库
@testing-library/jest-dom
,如我们平常经常使用的
expect().toBeInTheDocument()
就是该库为我们实现的,其通过
jest
提供的自定义断言器
expect.extend(matchers)
将断言库注入到我们所使用的的
jest
上下文中

查询方法

查询作为
testing-library
的核心,它主要以用户的角度去查询,如根据组件展示的文字,Title 等信息。

查询内容

从查询内容上来讲它提供了8种类型

  1. ByRole
    通过可访问性、语义化查询元素, 如
    checkbox
    ,
    menu
    ,
    navigation
    , 对于实际场景并不常用。

  2. ByLabelText
    通过 label 找到 label 所对应的元素,通常在表单中使用,如下

render(
  <label>
    username
    <input />
  </label>
);
const el = screen.getByLabelText('username');
expect(el.tagName).toBe('INPUT')
  1. ByPlaceHolder
    通过占位符查询元素,当查询表单元素等,但又没有 label 标识的话,可以使用这个,但不推荐。

  2. ByText
    最常用的查询方法,根据
    textContent
    进行查询,通常可配合正则一起使用,如下

render(<div>Text Content is: 1</div>);
const el = screen.getByText(/Text Content is/);
expect(el.textContent).toBe("Text Content is: 1");
  1. ByDisplayValue
    通过数值进行查询包含该值的元素,如 input 与 select 会有 value 属性
const {container} = render(
    <div>
    <select>
        <option value="state">state</option>
        <option value="prop">prop</option>
    </select>
    </div>
);
const select = container.querySelector('select')!;
select.value = 'state'
const el = screen.getByDisplayValue('state');
expect(el).toEqual(select)
  1. ByAltText
    根据
    alt
    属性进行查询,如
    <img alt=”img1” src=”xxx” />

  2. ByTitle
    根据
    title
    属性进行查询,同上

  3. ByTestId
    如果以上查询方式都不容易查询到节点,则最终可以考虑 testid,这种方式会侵入源代码,但不会对页面效果产生影响,通过在元素上添加
    data-testid
    属性来查询,查询时相当于
    container.querySelector([data-testid="${yourId}"])

有这么多方式,我该选哪个最好?

官方推荐优先使用用户页面可视的查询方式,如
byRole
,
byText
等可以在页面上看到的;其次是语义话查询方式,
byAltText

ByTitle
,这是在页面上基本看不到,但是易于机器读懂的;最后才应该考虑
byTestId
。如果
byTestId
也无法实现,那你只是使用原生的
querySelector
也没什么问题

查询方式

testing-library 一共提供了三种查询类型
getBy

findBy

queryBy
,这三种类型定义了对查询结果的处理方式。

通过
getBy
方式查询, 当查询不到元素时会直接抛出一个错误, 则导致测试失败。

我们看看下面这个案例,上面部分额外的显式使用了断言,其运行结果最终是一模一样的。

但应该采用哪种写法最好?

test("test getBy with assertion", () => {
    const { getByRole } = render(
        <div>
            <button>按钮</button>
        </div>
    );
    expect(getByRole("list")).toBeInTheDocument();
});

test("test getBy", () => {
    const { getByRole } = render(
        <div>
            <button>按钮</button>
        </div>
    );
    getByRole("list")
});

如果这段测试的意义是为了测试元素是否存在,则最佳实践应是采用使用显式断言的方式。

通过
queryBy
方式查询,与
getBy
的唯一不同就是查询不到元素时不会抛出错误。

那么什么情况下该使用
queryBy
还是
findBy

事实上,绝大多数情况下应直接使用
getBy
,只在想测试元素不存在这种场景时使用
queryBy

test("test queryBy", () => {
    const { queryByRole } = render(
        <div>
            <button>按钮</button>
        </div>
    );
    expect(queryByRole("list")).not.toBeInTheDocument()
});

通过
findBy
进行查询,他与
getBy
一致,查询不到时会抛出错误,但是它能够用来查询异步元素。

何为异步元素?

在前面我们讲过,setState 导致的异步渲染,我们已经通过React提供的
act
方法解决了,对于某些场景,比如上传文件后,显示已上传的文件列表,上传文件操作是异步的,我们需要在一定时间后才能拿到文件列表元素;又或者说
setTimeout
或者
promise
中进行了
setState
操作,渲染的元素也需要异步获取。

在讲
findBy
之前,我们先了解下
waitFor
,
waitFor
也是testing-library 提供的一个异步方法,它提供了一种对于不确定代码运行时间的处理方法。在使用时,必须使单测块变为异步的,否则就没了使用意义,因此
waitFor
一般都与
await
一起使用。

使用方式如下:

test("test waitFor", async () => {
    const Foo = () => {
        const [text, setText] = useState('text1')
        useEffect(() => {
            setTimeout(() => {
                setText('text2')
            }, 300);
        }, [])
        return <span>{text}</span>
    }

    const { getByText } = render(<Foo />);
    await waitFor(() => {
        expect(getByText('text2')).toBeInTheDocument()
    })
})

其原理也很简单,不断的去执行传入的回调函数,直到回调函数没有抛出错误或者超出最大等待时间。
expect
断言失败,本质上也是抛出个错误, 因此一般会把断言写在
waitFor
中。

waitFor
默认超时时间为1000ms,每50ms执行一次回调。但在测试环境我们也不可能真去等1秒时间,其内部做了额外处理。

  1. 默认会优先采用
    jest

    fakeTimers
    来略过时间等待,但这个前提是在执行
    waitFor
    前,需要进行
    fakeTimers
    的注册,也就是执行
    jest.useFakeTimers()

    但很多情况下我们是没有使用
    fakerTimers
    , 且 testing-library 是测试框架无关的,所以在其他情况下会使用
    MutationObserver
    来作为重复执行
    callback
    的时机。
  2. 在循环开始前会添加一个超时时间的定时器
    overallTimeoutTimer
    ,定时器回调被调用则说明超时,直接
    reject
    掉。
  3. 当采用
    fakeTimers
    方案时,会在每次循环时通过
    jest.advanceTimersByTime
    等待一定的时间
    interval
    (并非真正的等待)

  4. checkCallback
    方法中,会调用
    callback
    ,并进行异常捕获,如捕获到异常则会进行下一次的循环,如果正常则在
    onDone
    方法中将
    finished
    置为
    true
    ,并结束当前promise。
  5. 当使用
    MutationObserver
    方案时,会监听
    document
    DOM 节点的变化(包括其自己节点)。除此之外,由于
    MutationObserver
    是监听
    DOM树
    来实现的,某些场景会有限制,如 CSS 属性的变化,因此还会启用一个
    setInterval
    原始的定时器来做辅助执行,保证回调一定会被执行。
function waitFor(
  callback,
  {
    container = getDocument(),
    timeout = getConfig().asyncUtilTimeout,
    interval = 50,
    // 其他参数略
  },
) {
  return new Promise(async (resolve, reject) => {
    let lastError, intervalId, observer
    let finished = false
    let promiseStatus = 'idle'
		// 超时时间的timerid
    const overallTimeoutTimer = setTimeout(handleTimeout, timeout)
    const usingJestFakeTimers = jestFakeTimersAreEnabled()
    // 如果使用了jest的fakeTimers,则采用advanceTimersByTime快速略过时间
    if (usingJestFakeTimers) {
      const {unstable_advanceTimersWrapper: advanceTimersWrapper} = getConfig()
      checkCallback()
			// 不断的等待一定时间后并检测回调是否通过检测
      while (!finished) {
        await advanceTimersWrapper(async () => {
          jest.advanceTimersByTime(interval)
        })
				// 调用callback并检测是否跑错
        checkCallback()
        if (finished) {
          break
        }
      }
    } 
    // 如果没有使用fakeTimers,则退化为使用MutationObserver观测元素变化时执行一遍
    else {
      intervalId = setInterval(checkRealTimersCallback, interval)
      const {MutationObserver} = getWindowFromNode(container)
      observer = new MutationObserver(checkRealTimersCallback)
      observer.observe(container, mutationObserverOptions)
      checkCallback()
    }

		function checkCallback() {
      if (promiseStatus === 'pending') return
      try {
        const result = callback()
				// 处理callback为异步函数的情况
        if (typeof result?.then === 'function') {
          promiseStatus = 'pending'
          result.then(
            resolvedValue => {
              promiseStatus = 'resolved'
              onDone(null, resolvedValue)
            },
            rejectedValue => {
              promiseStatus = 'rejected'
              lastError = rejectedValue
            },
          )
        } else {
          onDone(null, result)
        }
      } catch (error) {
        lastError = error
      }
    }

    function onDone(error, result) {
      finished = true
      clearTimeout(overallTimeoutTimer)

      if (!usingJestFakeTimers) {
        clearInterval(intervalId)
        observer.disconnect()
      }

      if (error) {
        reject(error)
      } else {
        resolve(result)
      }
    }
}

从源码中看其实现还是很巧妙的,额外还需要注意的点是,回调函数是支持传入 async 函数的,当传入 async 函数时,会等待 promise 状态改变后才会再次执行。

再回到
findBy
, 它其实就是对
waitFor
的一个封装,类似于下面这种代码。

await waitFor(() => getByXXX())

在使用时也必须加上
await
关键字,并且当你想要使用
findBy
时,请确保元素最终一定会存在,如果你想要测试元素是否存在,请使用
waitFor + expect
的形式保证其具有足够的语义。

test("test findBy", async () => {
    const Foo = () => {
        const [text, setText] = useState('text1')
        useEffect(() => {
            setTimeout(() => {
                setText('text2')
            }, 300);
        }, [])
        return <span>{text}</span>
    }

    const { findByText } = render(<Foo />);
    const span = await findByText('text2');
    expect(span.nodeName).toBe('SPAN')
})

除此之外,所有查询方法都是严格区分数量的,如果查询结果数量返回大于1,即使是
queryBy
类型,也会报错导致测试失败,对于多个返回的,需要使用
getAllBy
,
queryAllBy
,
findAllBy

贴一张文档上的区别图

file

事件触发

testing-library 提供了两种触发事件的方式,
fireEvent

userEvent

fireEvent

fireEvent
是从 React Testing LIbrary 中引入的,其内部又是基于 DOM Testing Library 的
fireEvent

React
做了一些兼容性改动。
其使用方式非常方便, 有
fireEvent(node, event)
或者
fireEvent(node, eventProperties)
两种使用方式

fireEvent.change(getByLabelText(/picture/i), {
  target: {
    files: [new File(['(⌐□_□)'], 'chucknorris.png', {type: 'image/png'})],
  },
});

fireEvent(getByLabelText(/picture/i), new Event('change', 
	{bubbles: true, cancelable: false})
);

在 DOM Testing Library 中
fireEvent
的实现, 也是通过
dispatchEvent
来做的

function fireEvent(element, event) {
  return getConfig().eventWrapper(() => {
    if (!event) {
      throw new Error(
        `Unable to fire an event - please provide an event object.`,
      )
    }
    if (!element) {
      throw new Error(
        `Unable to fire a "${event.type}" event - please provide a DOM element.`,
      )
    }
    return element.dispatchEvent(event)
  })
}

那我们看看 React Testing Library 中又做了啥,它其实是对
fireEvent
加了层
act
包裹,这也是我们能直接使用的原因,
fireEvent
时切记不要再手动包裹
act

configureDTL({
  eventWrapper: cb => {
    let result
    act(() => {
      result = cb()
    })
    return result
  },
	// 略
})

我们实际开发中经常会时不时的遇到
act
的飘红报错,看到报错提示我们不经意间就加了个
act
上去,但这样其实是没用的。

file

比如下面这个案例:

test("test act warning", () => {
    const Foo = () => {
        const [text, setText] = useState('text1')
        useEffect(() => {
            Promise.resolve().then(() => setText('text2'))
        }, [])
        return <div>
                <span>{text}</span>
                <div>haha</div>
            </div>
    }

    const { getByText, debug } = render(<Foo />);
    const text = getByText('haha');
    expect(text).toBeInTheDocument()
    debug()
})

当我们代码中进行异步请求时,并在测试完成后或者 act 执行完成后,再在回调中进行
setState
则会导致报错。

上面这段代码想要修复报错,有很多方式,如可以在测试结束前进行等待来解决,或者直接干脆把异步请求的返回
mock
掉,不进行
setState

优化后的代码

test("test act warning", async () => {
    const Foo = () => {
        const [text, setText] = useState('text1')
        const fn = 
        useEffect(() => {
            Promise.resolve().then(() => setText('text2'))
        }, [])
        return <div>
                <span>{text}</span>
                <div>haha</div>
            </div>
    }

    const { getByText, findByText } = render(<Foo />);
    const text = getByText('haha');
    expect(text).toBeInTheDocument()
		// 等待后再结束测试
    await findByText('text2')
})

又或者使用异步的
act
, 在初次
render
时 手动包裹一层
act
,
act
是支持嵌套使用的。这在初始化组件时请求异步数据很有用

await act(async () => render(<Foo />) )
const text = screen.getByText('text2');
expect(text).toBeInTheDocument()

如果我再加入个异步任务,结果又如何?

useEffect(() => {
  Promise.resolve().then(() => setText('text2')).then(() => setText('text3'))
}, [])

答案是:tex3

userEvent

userEvent
是 testing library 的单独一个测试包,需要从
@testing-library/user-event
中引入。


fireEvent
不同的是,该包是完全以模拟用户的真实行为去触发事件的。

fireEvent
是浏览器低级
dispatchEvent
API 的轻量级包装器,它允许开发人员触发任何元素上的任何事件。问题在于,浏览器通常不仅仅为一次交互触发一个事件。例如,当用户在文本框中键入内容时,必须聚焦该元素,然后触发键盘的输入事件。
userEvent
其实就是真实模拟了用户使用时的交互方式。

下面是个简单的输入案例。

test("test userEvent", async () => {
    const onChange = jest.fn();
    const onFocus = jest.fn();
    const onClick = jest.fn();
    const { getByPlaceholderText } = render(
        <input
            placeholder="请输入"
            type="textarea"
            onChange={onChange}
            onFocus={onFocus}
            onClick={onClick}
        />
    );
    const input = getByPlaceholderText("请输入");
    await userEvent.type(input, 'hello');
    expect(onChange).toHaveBeenCalled();
    expect(onFocus).toHaveBeenCalled();
    expect(onClick).toHaveBeenCalled();
    expect(input).toHaveDisplayValue('hello')
})

需要注意的是,
userEvent
由于模拟了一系列操作,需要以异步的形式调用才能获取结果。

userEvent
还提供了很多其他的模拟操作,如复制粘贴,模拟键盘打字,模拟文件上传等等用户交互场景。

值得注意的是,testing-library 官方是推荐我们在大多数情况下应优先考虑使用
userEvent
而非
fireEvent
的,因为
您的测试越接近软件的使用方式,它们就越能给您带来信心。

产品中的一些反模式

  1. 不要再在无用的地方加
    cleanup
    了**。

file

首先为何需要有
cleanup
清除函数?
在一个单测文件中我们可能有多个
test
,每个测试实例渲染自己的组件,但是
window.document
只有一个,每次
render()
都会往
body
下添加一个
div
作为根容器,我们保证测试自己的组件时
body
下是无子节点的避免影响。
cleanup
会对当前所有挂载的React根组件进行
unmount
, 并移除对应的元素。

function cleanup() {
  mountedRootEntries.forEach(({root, container}) => {
    act(() => {
      root.unmount()
    })
    if (container.parentNode === document.body) {
      document.body.removeChild(container)
    }
  })
  mountedRootEntries.length = 0
  mountedContainers.clear()
}

为何不需要再
cleanup
了?RTL中自动帮我们调了。

何时才需要
cleanup

同一个测试块中,
render
了多次使其挂载了多个组件根节点, 也就是在
test
代码块内进行调用。

file

  1. 错误的使用
    waitFor
    waitFor
    中语句要有抛错的能力才有实际意义
let el = null
await waitFor(() => {
    el = document.querySelector('.xxx')
})
await waitFor(() => {
    fireEvent.click(el);
		expect(el).toBeDisabled();
})

  1. fireEvent
    包裹无意义的
    act
// 错误的
act(() => {
	fireEvent.click(el)
})

// 可能正确的方式
await act(async () => {
	fireEvent.click(el)
})

tips: 更多的反模式参考
一些常见的RTL错误

参考:

https://www.robinwieruch.de/react-testing-library/
https://testing-library.com/docs/queries/about#priority

最后

欢迎关注【袋鼠云数栈UED团队】~
袋鼠云数栈 UED 团队持续为广大开发者分享技术成果,相继参与开源了欢迎 star