You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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表单体系深度绑定。之前的测试代码问题在于:

  1. 直接操作原生select元素选中选项时,没有触发组件绑定的input事件,导致组件的inputChanged方法未执行
  2. 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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.25 10:33:58