介绍
使用 Angular 和 Spring Boot 组合构建现代 Web 应用程序在大型和小型企业中都非常流行。 Angular 提供了所有必要的工具来构建一个健壮、快速和可扩展的前端,而 Spring Boot 为后端完成了同样的工作,而无需配置和维护 Web 应用程序服务器。
\ 确保构成最终产品的所有软件组件协同工作,它们必须一起进行测试。这就是使用 Serenity BDD 进行集成测试的地方。Serenity BDD 是一个开源库,有助于编写更清晰、更可维护的自动化验收和回归测试。
\
:::info BDD – 行为驱动开发是一种测试技术,涉及以简单的以业务为中心的语言表达应用程序的行为方式。
:::
\
目标
本文的目标是构建一个简单的 Web 应用程序,该应用程序试图根据一个人的名字来预测他们的年龄。然后,使用 Serenity BDD 库编写一个集成测试,以确保应用程序正常运行。
\
构建 Web 应用程序
首先,重点将放在 Spring Boot 后端。将使用 Spring RestController 公开 GET API 端点。当使用人名调用端点时,它将返回该名称的预测年龄。实际预测将由agify.io处理。
\ 接下来,将实现一个向用户呈现文本输入的 Angular 应用程序。当在输入中输入名称时,将向后端触发 HTTP GET 请求以获取年龄预测。然后应用程序将接受预测,并将其显示给用户。
\
:::tip 本文的完整项目代码可在GitHub 上获取
:::
\
构建后端
首先定义年龄预测模型。它将采用带有name
和age
的 Java 记录的形式。此处还将定义一个空的年龄预测:
\ AgePrediction.java
public record AgePrediction(String name, int age) { private AgePrediction() { this("", 0); } public static AgePrediction empty() { return new AgePrediction(); } }
RestController 处理对/age/prediction
的 HTTP 调用。它定义了一个 GET 方法,该方法接收名称并访问api.agify.io以获取年龄预测。该方法使用@CrossOrigin
注释以允许来自 Angular 的请求。如果未提供name
参数,则该方法仅返回一个空的年龄预测。
\ 为了对预测进行实际调用,将使用 Spring 的 REST 客户端 — RestTemplate:
\ AgePredictionController.java
@RestController @RequestMapping("/age/prediction") @RequiredArgsConstructor public class AgePredictionController { private final static String API_ENDPOINT = "https://api.agify.io"; private final RestTemplate restTemplate; /** * Tries to predict the age for the provided name. * * If name is empty, an empty prediction is returned. * * @param name used for age prediction * @return age prediction for given name */ @CrossOrigin(origins = "http://localhost:4200") @GetMapping public AgePrediction predictAge(@RequestParam(required = false) String name) { if (StringUtils.isEmpty(name)) { return AgePrediction.empty(); } HttpHeaders headers = new HttpHeaders(); headers.set(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE); HttpEntity<?> entity = new HttpEntity<>(headers); return restTemplate.exchange(buildAgePredictionForNameURL(name), HttpMethod.GET, entity, AgePrediction.class).getBody(); } private String buildAgePredictionForNameURL(String name) { return UriComponentsBuilder .fromHttpUrl(API_ENDPOINT) .queryParam("name", name) .toUriString(); } }
\
构建前端
年龄预测模型将被定义为具有name
和age
的接口:
\年龄预测.model.ts
export interface AgePredictionModel { name: string; age: number; }
该网页将包含一个文本<input>
,用户将在其中键入用于年龄预测的姓名,以及两个<h3>
元素,其中将显示姓名和预测年龄。
当用户输入<input>
时,文本将通过onNameChanged($event)
函数传递给 typescript 类。
\ 显示name
和预测age
是通过订阅agePrediction$
observable 来处理的。
\ app.component.html
<div> <label>Enter name to get age prediction: </label> <input id="nameInput" type="text" (input)="onNameChanged($event)"/> </div> <div> <h3> Name: <span id="personName"></span> </h3> </div> <div> <h3> Age: <span id="personAge"></span> </h3> </div>
至于 Angular 组件,它会在<input>
上通过函数onNameChanged($event)
发生变化时被调用。该事件被转换为一个名为agePrediction$
的可观察对象,通过管道将 HTTP GET 发送到具有最新名称的后端。这是通过使用 Subject nameSubject
和 RxJs 运算符 debounceTime、distinctUntilChanged、switchMap、shareReplay 来实现的。
\
:::信息
- debounceTime – 仅在经过特定时间跨度且没有另一个源发射后才从源 Observable 发射一个值
- distinctUntilChanged – 如果源 observable 推送的所有值与 observable 发出的最后一个值相比是不同的,则发出它们
- switchMap – 将每个源值投影到一个 Observable 中,该 Observable 合并到输出 Observable 中,仅从最近投影的 Observable 发出值
- shareReplay – 共享源并在订阅时重播指定数量的排放
:::
\ app.component.ts
@Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'] }) export class AppComponent implements OnInit { static readonly AGE_PREDICTION_URL = 'http://localhost:8080/age/prediction'; agePrediction$: Observable<AgePredictionModel>; private nameSubject = new Subject<string>(); constructor(private http: HttpClient) { } ngOnInit() { this.agePrediction$ = this.nameSubject.asObservable().pipe( debounceTime(300), distinctUntilChanged(), switchMap(this.getAgePrediction), shareReplay() ); } /** * Fetches the age prediction model from our Spring backend. * * @param name used for age prediction */ getAgePrediction = (name: string): Observable<AgePredictionModel> => { const params = new HttpParams().set('name', name); return this.http.get<AgePredictionModel>(AppComponent.AGE_PREDICTION_URL, {params}); } onNameChanged($event) { this.nameSubject.next($event.target.value); } }
\ 年龄预测页面预览:
\
\
编写集成测试
作为测试 Web 应用程序的第一步,创建一个抽象测试类来封装 Serenity 测试所需的逻辑:
\
- Actor 代表使用被测应用程序的人或系统——这里简称为
tester
- WebDriver 是一个用于控制网络浏览器的接口。通过指定
@Managed
注解,Serenity 会将默认配置的实例注入browser
- 在
setBaseUrl()
方法中,用于所有测试的基本 URL 是在 Serenity 的 EnvironmentVariables 中配置的。这是为了避免重复每个测试页面的协议、主机和端口
\ AbstractIntegrationTest.java
public abstract class AbstractIntegrationTest { @Managed protected WebDriver browser; protected Actor tester; private EnvironmentVariables environmentVariables; @BeforeEach void setUp() { tester = Actor.named("Tester"); tester.can(BrowseTheWeb.with(browser)); setBaseUrl(); } private void setBaseUrl() { environmentVariables.setProperty(WEBDRIVER_BASE_URL.getPropertyName(), "http://localhost:4200"); } }
为了测试年龄预测页面,创建了一个继承自 PageObject(浏览器中的页面表示)的新 IndexPage 类。相对于先前指定的基本 URL 的页面 URL 是使用@DefaultUrl
注释定义的。
页面上的 HTML 元素使用 Serenity Screenplay 流畅地定义。
\ IndexPage.java
@DefaultUrl("/") public class IndexPage extends PageObject { public static final Target NAME_INPUT = the("name input").located(By.id("nameInput")); public static final Target PERSON_NAME = the("name header text").located(By.id("personName")); public static final Target PERSON_AGE = the("age header text").located(By.id("personAge")); }
最后,编写集成测试意味着继承自 AbstractIntegrationTest 的类,并使用 JUnit 的@ExtendWith
和 Serenity 的 JUnit 5 扩展进行注释。 indexPage
将在测试运行时由 Serenity 注入。在 BDD 方式中,测试以 given-when-then 块的形式构建。
\ 阅读测试试图达到的目标几乎和阅读简单的英语一样简单:
\
-
‘given’ 语句将尝试在年龄预测页面上打开浏览器。
-
‘when’ 语句将获取
<input>
的句柄并输入文本“Andrei”。 -
‘then’ 语句将评估 4 个语句:
-
验证人名
<h3>
在页面上是否可见 -
验证页面上显示的人名是否是预期的人名
-
验证人年龄
<h3>
在页面上是否可见 -
验证人的年龄是否是一个数字(不检查固定年龄,因为年龄预测可能会改变)
\
eventually
通过在通过/失败测试条件之前等待 5 秒来适应较慢的后端响应。
\ IndexPageTest.java
@ExtendWith(SerenityJUnit5Extension.class) public class IndexPageTest extends AbstractIntegrationTest { private static final String TEST_NAME = "Andrei"; private IndexPage indexPage; @Test public void givenIndexPage_whenUserInputsName_thenAgePredictionIsDisplayedOnScreen() { givenThat(tester).wasAbleTo(Open.browserOn(indexPage)); when(tester).attemptsTo(Enter.theValue(TEST_NAME).into(NAME_INPUT)); then(tester).should( eventually(seeThat(the(PERSON_NAME), isVisible())), eventually(seeThat(the(PERSON_NAME), containsText(TEST_NAME))), eventually(seeThat(the(PERSON_AGE), isVisible())), eventually(seeThat(the(PERSON_AGE), isANumber())) ); } private static Predicate<WebElementState> isANumber() { return (htmlElement) -> htmlElement.getText().matches("\\d*"); } }
\
概括
本文简要介绍了如何使用 Serenity BDD 来实现现代 Web 应用程序的集成测试。执行测试所需的配置量保持在最低限度,用于测试网页的生成代码阅读起来非常愉快,以至于您想知道它是如何工作的!
\
:::info我不是由上面列出的任何产品/服务/公司赞助或收到任何补偿。本文仅供参考。
:::
\
参考
- https://serenity-bdd.github.io/theserenitybook/latest/index.html
- https://medium.com/javascript-scene/behavior-driven-development-bdd-and-functional-testing-62084ad7f1f2
- https://github.com/serenity-bdd/serenity-core
- https://agify.io/
- https://rxjs.dev/api/operators/
\
原文: https://hackernoon.com/angular-and-spring-boot-using-serenity-bdd-for-integration-testing?source=rss