Angular自定义Select组件的Selenium自动化测试值未同步问题求助
Angular自定义下拉组件的Selenium自动化测试解决方案
问题背景
我是一名开发人员,正在协助公司BQA团队搭建测试自动化体系。我们的核心业务系统是基于Angular 7-10开发的大型复杂网站,由外包团队负责开发。我使用Visual Studio、C#和NUnit实现Selenium WebDriver自动化测试,目前遇到的最大障碍是自定义下拉列表组件的测试。
我熟悉C#,略懂JavaScript,正在学习ES5、ES6、NodeJS、TypeScript及Angular相关技术。
我尝试用Selenium内置函数选择列表选项,但选中的值从未在服务端完成注册,如果该字段是必填项,提交时总会触发校验警告。我试过以下两种实现方式:
尝试方案1:使用Selenium SelectElement
var driver = new ChromeDriver(); driver.FindElement(By.XPath("//app-select[@id='myId']")).Click(); new SelectElement(driver.FindElement(By.XPath("//app-select[@id='myId']//select"))).SelectByText("Some Option");
尝试方案2:通过JavaScript执行器设置选中状态
var js = (IJavaScriptExecutor)driver; var appSelect = new SelectElement(driver.FindElement(By.XPath("//app-select[@id='myId']//select"))); foreach(var opt in appSelect.Options) { if(opt.Text == "Some Option") { js.ExecuteScript("arguments[0].selected=true", opt); // 效果等同于SelectByText() js.ExecuteScript("arguments[0].input", appSelect); // 这些代码没有生效 js.ExecuteScript("arguments[0].change", appSelect); // 无法触发组件的状态更新 break; } }
我可以访问网站代码库查看该自定义组件,但对Angular不太熟悉。我怀疑是Angular表单的绑定变量未更新,不确定能否通过客户端JavaScript触发更新使脚本正常工作。
自定义组件代码分析
select.component.html
<select #innerSelect [attr.id]="uid" class="select" [value]="value" [disabled]="disabled" (blur)="onBlur()" (input)="inputChanged($event)"> <ng-content></ng-content> </select> <div class="arrow-icon" aria-hidden="true"></div>
select.component.ts
import { Component, ViewEncapsulation, forwardRef, Output, EventEmitter, Input, OnInit, Renderer2, ElementRef, ViewChild, AfterViewInit, OnDestroy } from '@angular/core'; import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms'; import { ContentObserver } from '@angular/cdk/observers'; import { Subscription } from 'rxjs'; let nextUniqueId = 0; /** A wrapper for a native select component. This allows us to render out a drop down arrow that is inline with branding. */ @Component({ selector: 'app-select', templateUrl: './select.component.html', styleUrls: ['./select.component.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SelectComponent), multi: true } ], encapsulation: ViewEncapsulation.None }) export class SelectComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnDestroy { /** When dataType is set to "number", value will be converted to a number on get. */ @Input() dataType; /** Whether the component is disabled. */ @Input() disabled = false; /** Value of the control. */ @Input() get value(): any { return this._value; } set value(newValue: any) { if (newValue !== this._value) { this.writeValue(newValue); this._value = newValue; } } private _value: any; /** Unique id of the element. */ @Input() get uid(): string { return this._uid; } set uid(value: string) { this._uid = value || this._defaultId; } private _uid: string; /** tracks changes to the content of the inner select (options) */ private contentChanges: Subscription = null; /** appAutoFocus directive does not work here, so we implement autoFocus ourselves */ @Input() autoFocus = false; /** * Event that emits whenever the raw value of the select changes. This is here primarily * to facilitate the two-way binding for the 'value' input. */ @Output() readonly valueChange: EventEmitter<any> = new EventEmitter<any>(); @ViewChild('innerSelect', { static: true }) innerSelect: ElementRef; /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// /** Unique id for this input. */ // tslint:disable-next-line:member-ordering private _defaultId = `app-select-${nextUniqueId++}`; /** View -> model callback called when value changes */ onChange: (value: any) => void = () => { }; /** View -> model callback called when select has been touched */ onTouched = () => { }; /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// constructor(private el: ElementRef, private renderer: Renderer2, private obs: ContentObserver) { // Force setter to be called in case id was not specified. this.uid = this.uid; } ngOnInit() { this.autoFocus = this.el.nativeElement.hasAttribute('autoFocus'); } ngAfterViewInit() { // Watch for changes in the content of the select control. // This is in place for situations where the list of options comes in *after* the value of the select has been set. // If that occurs, re-set the value of the select to force the browser to pick the right option in the drop down list. this.contentChanges = this.obs.observe(this.innerSelect.nativeElement).subscribe((event: MutationRecord[]) => { this.innerSelect.nativeElement.value = this._value; }); if (this.autoFocus === true) { setTimeout(() => { this.innerSelect.nativeElement.focus(); }, 200); } if (this.el.nativeElement.hasAttribute('id')) { console.warn('app-select has an "id". Use "uid" to set the id of the inner select element.'); } } ngOnDestroy() { this.contentChanges.unsubscribe(); } /** * Sets the select's value. Part of the ControlValueAccessor interface * required to integrate with Angular's core forms API. */ writeValue(value: any): void { this._value = value; } /** * Saves a callback function to be invoked when the select's value * changes from user input. Part of the ControlValueAccessor interface * required to integrate with Angular's core forms API. */ registerOnChange(fn: (value: any) => void): void { this.onChange = fn; } /** * Saves a callback function to be invoked when the select is blurred * by the user. Part of the ControlValueAccessor interface required * to integrate with Angular's core forms API. */ registerOnTouched(fn: () => {}): void { this.onTouched = fn; } /** * Disables the select. Part of the ControlValueAccessor interface required * to integrate with Angular's core forms API. */ setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; } onBlur() { this.onTouched(); } inputChanged(event) { const targetValue = event.target.value; if (targetValue && targetValue.toLowerCase() === 'null') { this.value = null; } else if (this.dataType === 'number') { // if number is specified, attempt to convert the value this.value = +targetValue; } else if (this.dataType === 'string') { // if string is specified, attempt to convert the value this.value = '' + targetValue; } else if (targetValue === '') { // treat empty string as null this.value = null; } else if (!isNaN(targetValue)) { // if no dataType is specified, attempt to determine if the value is a number this.value = +targetValue; } else { // otherwise use the value as is (should be a string) this.value = targetValue; } this.onChange(this.value); } }
select.component.scss
app-select { position: relative; display: block; width: 19em; // default drop-down width? select { display: inline-block; position: relative; width: 100%; padding: .37em .4em; padding-right: 44px; font-size: 1.125em; color: #222; border: 1px solid #000; border-radius: 4px; background: white; height: 38px; background: linear-gradient(to left, #ffcd41 0, #ffcd41 21px, #484848 21px, white 22px); box-shadow: 0px 0px 2px 0px rgba(0, 0, 0, 0.5); transition: border linear 0.2s, box-shadow linear 0.2s; text-overflow: ellipsis; option { color: #222; font-size: .9em; padding: .2em; &.default { font-weight: 600; color: black; } &.legacy { color: #333; } } optgroup { &.legacy { background: #eeeeee; color: #333; } } &[readonly] { border-color: #bbb; color: black; background-color: #f7f7f7; -webkit-appearance: none; // hide drop down arrow } &.ng-dirty { color: black; } &[disabled]:not([readonly]) { background: #E6E6E6; opacity: 1; } } .arrow-icon { display: block; position: absolute; right: 1px; top: 1px; bottom: 1px; width: 44px; background: #ffcd41; border-left: 1px solid #000; pointer-events: none; border-radius: 0 4px 4px 0; padding-top: 7px; padding-left: 11px; font-size: 1.4em; line-height: normal; &:before { font-family: "custom" !important; content: "\71"; } } .select[disabled]:not([readonly])+.arrow-icon { background-color: #E6E6E6; } .select:focus+.arrow-icon { right: 2px; top: 2px; bottom: 2px; width: 43px; padding-top: 6px; } .select:focus-within { box-shadow: inset 0 0 0 1px #000; } }
问题根源分析
这个app-select是实现了ControlValueAccessor接口的Angular自定义表单组件,和Angular表单体系深度绑定。之前的测试代码问题在于:
- 直接操作原生
select元素选中选项时,没有触发组件绑定的input事件,导致组件的inputChanged方法未执行 inputChanged方法是组件更新内部_value并调用onChange通知表单控件同步值的关键,跳过这一步会导致Angular表单的模型值未更新,服务端自然接收不到选中值,进而触发必填校验警告
有效解决方案
方案1:模拟用户真实操作(推荐)
完全模拟用户点击下拉、选择选项的行为,会自然触发组件的input和blur事件,符合Angular组件的交互逻辑:
var driver = new ChromeDriver(); // 点击自定义组件触发下拉展开(注意用uid定位,避免组件控制台警告) driver.FindElement(By.XPath("//app-select[@uid='myId']")).Click(); // 定位到目标选项并点击(确保选项处于可见状态) driver.FindElement(By.XPath("//app-select[@uid='myId']//option[text()='Some Option']")).Click();
方案2:通过JavaScript触发组件事件
如果模拟点击存在困难(比如选项被动态隐藏),可以通过JS执行器设置原生select的值后,手动触发input事件,强制组件更新状态:
var js = (IJavaScriptExecutor)driver; // 定位内部原生select元素 var innerSelect = driver.FindElement(By.XPath("//app-select[@uid='myId']//select")); // 设置目标选项的value值(注意是选项的value属性,不是显示文本) string targetValue = "选项对应的value值"; js.ExecuteScript("arguments[0].value = arguments[1];", innerSelect, targetValue); // 触发input事件,让组件执行inputChanged逻辑 js.ExecuteScript("const event = new Event('input', { bubbles: true }); arguments[0].dispatchEvent(event);", innerSelect); // 可选:触发blur事件,标记控件为已触碰,避免未触碰的校验警告 js.ExecuteScript("const event = new Event('blur', { bubbles: true }); arguments[0].dispatchEvent(event);", innerSelect);
方案3:直接调用Angular组件实例方法
如果网站开启了Angular调试模式,可以直接获取组件实例并更新值,这种方式最直接但依赖Angular内部API,版本升级可能失效:
var js = (IJavaScriptExecutor)driver; // 获取app-select组件实例 var component = js.ExecuteScript(@" const el = arguments[0]; // Angular 7-9可用ng.probe,Angular 10+可能需要调整为ng.getComponent return ng.probe(el).componentInstance; ", driver.FindElement(By.XPath("//app-select[@uid='myId']"))); // 设置组件value string targetValue = "目标值"; js.ExecuteScript("arguments[0].value = arguments[1];", component, targetValue); // 调用onChange通知表单更新 js.ExecuteScript("arguments[0].onChange(arguments[1]);", component, targetValue); // 可选:调用onTouched标记为已触碰 js.ExecuteScript("arguments[0].onTouched();", component);
内容的提问来源于stack exchange,提问作者Gene Yuss
相关产品推荐
相关产品推荐

