R004: 测试用例缺少断言

SkillDev tools

A skill for dev tools by openharmonyinsight.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the R004: 测试用例缺少断言 skill

What this skill tells your AI

The instructions your AI receives, as published by openharmonyinsight/openharmony-skills in skills/check-test-code-quality/rules/R004/SKILL.md and read by ahel’s review.

文档导航

本文档较长(L5最复杂规则),按需阅读:

  • 快速执行: 直接使用 R004专用扫描脚本 scan_r004_v3_generic.py
  • 理解检测流程: 检测逻辑总览(8步流程图)
  • 核心实现: 步骤1-5(it块提取、断言检查、递归间接断言)
  • 关键陷阱: 陷阱#1(字符串大括号)、陷阱#1b(反引号撇号)
  • try-catch处理: 步骤6-7
  • 修复建议格式: 修复建议格式规范

规则概述

属性
规则编号R004
问题类型测试用例缺少断言
严重级别Critical
复杂度L5(最复杂规则)
扫描范围所有源代码文件(.ets, .ts, .js

问题描述

测试用例(it())中完全没有断言。断言是测试用例的核心,用于验证被测功能是否符合预期。没有断言的测试用例无法验证任何功能,等同于无效测试。

修复方法

it() 块中添加有效的断言方法,检查实际业务逻辑结果。

Hypium 框架支持的断言方法

以下断言方法均视为有效断言:

assertClose           assertContain           assertEqual
assertFail            assertFalse             assertTrue
assertInstanceOf      assertLarger            assertLess
assertLargerOrEqual   assertLessOrEqual       assertNull
assertThrowError      assertUndefined         assertNaN
assertNegUnlimited    assertPosUnlimited      assertDeepEquals
expect(...)           (配合 .assert* 链式调用)

检测逻辑总览

┌─────────────────────────────────────────────────────────┐
│                    R004 扫描流程                         │
├─────────────────────────────────────────────────────────┤
│  1. 找到所有 it() 块                                    │
│  2. 提取函数体内容(字符串感知的大括号匹配)               │
│  3. 检查直接断言 → 有则跳过                              │
│  4. 收集所有函数定义(本文件 + 跨文件 import)            │
│  5. 递归检查间接断言(最大深度5层)                       │
│  6. try-catch 断言检测(两分支都必须有断言)              │
│  7. 辅助函数 try-catch 缺陷检测(Warning级别)            │
│  8. 生成具体修复建议                                     │
└─────────────────────────────────────────────────────────┘

核心检测步骤详解

步骤1:找到所有 it() 块

使用正则表达式匹配 it() 函数调用,提取测试用例名称、行号、列位置:

def find_it_blocks(content):
    it_blocks = []
    lines = content.split('\n')
    for i, line in enumerate(lines):
        stripped = line.strip()
        # 匹配 it('name', ...) 或 it("name", ...) 模式
        m = re.match(r"it\s*\(\s*['\"]([^'\"]+)['\"]\s*,\s*(.*)", stripped)
        if not m:
            m = re.match(r"it\s*\(\s*['\"]([^'\"]+)['\"]\s*,\s*", stripped)
            if m:
                it_blocks.append({
                    'name': m.group(1),
                    'line': i + 1,
                    'col': m.end(),
                    'full_line': line,
                    'rest': stripped[m.end():],
                })
            continue
        it_blocks.append({
            'name': m.group(1),
            'line': i + 1,
            'col': m.start(),
            'full_line': line,
            'rest': m.group(2),
        })
    return it_blocks

步骤2:提取函数体内容(字符串感知的大括号匹配)

⚠️ 陷阱 #1(CRITICAL):字符串字面量中的大括号

必须跳过字符串字面量内的 {},使用状态机解析。朴素的大括号计数曾导致 53,951 个误报。

def count_braces_outside_strings(text, start_idx=0):
    """
    统计大括号数量,跳过字符串字面量和模板字符串。
    使用状态机追踪 in_single, in_double, in_backtick 状态。
    """
    open_count = 0
    close_count = 0
    in_single = False
    in_double = False
    in_backtick = False
    i = start_idx
    while i < len(text):
        ch = text[i]
        if ch == '\\' and (in_single or in_double or in_backtick):
            i += 2  # 跳过转义字符
            continue
        if not in_single and not in_double and not in_backtick:
            if ch == '{':
                open_count += 1
            elif ch == '}':
                close_count += 1
        if ch == '`' and not in_single and not in_double:
            in_backtick = not in_backtick
        elif ch == "'" and not in_double and not in_backtick:
            in_single = not in_single
        elif ch == '"' and not in_single and not in_backtick:
            in_double = not in_double
        i += 1
    return open_count, close_count
def find_matching_brace(content, start_idx, open_char='{', close_char='}'):
    """
    带字符串感知的大括号匹配。
    跳过单引号、双引号、反引号字符串中的大括号。
    """
    depth = 0
    i = start_idx
    while i < len(content):
        if content[i] == open_char:
            depth += 1
        elif content[i] == close_char:
            depth -= 1
            if depth == 0:
                return i
        elif content[i] == '"' or content[i] == "'":
            quote = content[i]
            j = i + 1
            while j < len(content):
                if content[j] == '\\':
                    j += 2
                    continue
                if content[j] == quote:
                    i = j
                    break
                j += 1
        elif content[i] == '`':
            j = i + 1
            while j < len(content):
                if content[j] == '\\':
                    j += 2
                    continue
                if content[j] == '`':
                    i = j
                    break
                j += 1
        i += 1
    return -1

⚠️ 陷阱 #1b(CRITICAL):反引号模板字符串中的撇号/引号干扰

TypeScript/JavaScript的反引号模板字符串(`...`)中可能包含撇号或引号。如果状态机不追踪反引号状态,会将模板字符串内的'误识别为单引号字符串定界符,导致大括号匹配错误。

触发条件: it()块内使用反引号模板字符串,且字符串中包含'"

典型代码:

it('testGetPath', Level.LEVEL3, async (done: Function) => {
  try {
    let path = certManager.getCertificateStorePath(property);
    // 下面这行反引号模板字符串中包含 user's
    console.info(`Success to get user's path: ${path}`);
    // 如果没有 in_backtick 追踪:
    //   's path: ${path}' 被当成单引号字符串开始
    //   后续 } catch (err) { ... } 中的 } 被跳过
    //   it()函数体范围错误延伸,断言检测失效
    expect(path).assertEqual('/data/certificates/user_cacerts/100');
  } catch (err) {
    expect(null).assertFail();
  }
});

影响: 有断言的用例被误判为缺少断言(R004误报),或it()/describe()块范围错误(R018误报)。

修复: 在所有大括号匹配的状态机中增加in_backtick状态:

# 在匹配单引号/双引号时,必须同时检查不在反引号字符串内
if ch == '`' and not in_single and not in_double:
    in_backtick = not in_backtick
elif ch == "'" and not in_double and not in_backtick:  # 必须加 not in_backtick
    in_single = not in_single
elif ch == '"' and not in_single and not in_backtick:   # 必须加 not in_backtick
    in_double = not in_double

影响范围: R004(it()块范围提取), R018(describe块范围提取), 以及任何使用大括号匹配解析代码结构的规则。

def extract_block_content(content, start_line, block_start_col):
    """
    从 it() 所在行提取函数体内容。
    定位 => 箭头,然后提取 { } 块。
    """
    lines = content.split('\n')
    idx = start_line - 1
    if idx < 0 or idx >= len(lines):
        return "", start_line
    line = lines[idx]
    pos_in_line = block_start_col

    # 查找箭头 =>
    arrow_idx = -1
    for prefix in ['=>', '=> ']:
        pidx = line.find(prefix, pos_in_line)
        if pidx != -1:
            arrow_idx = pidx
            break
    if arrow_idx == -1:
        return "", start_line

    # 查找函数体起始大括号
    brace_idx = line.find('{', arrow_idx + 2)
    if brace_idx == -1:
        return "", start_line

    # 从当前行开始拼接完整文本,匹配大括号
    full_text = '\n'.join(lines[idx:])
    block_start = brace_idx
    block_end = find_matching_brace(full_text, block_start, '{', '}')
    if block_end == -1:
        return "", start_line

    block_content = full_text[block_start + 1:block_end]
    block_start_line = start_line + full_text[:block_start].count('\n')
    return block_content, block_start_line

步骤3:检查直接断言

ASSERTION_PATTERNS = [
    re.compile(r'\bexpect\s*\('),
    re.compile(r'\bassertEqual\s*\('),
    re.compile(r'\bassertNotEqual\s*\('),
    re.compile(r'\bassertTrue\s*\('),
    re.compile(r'\bassertFalse\s*\('),
    re.compile(r'\bassertNull\s*\('),
    re.compile(r'\bassertNotNull\s*\('),
    re.compile(r'\bassertUndefined\s*\('),
    re.compile(r'\bassertDefined\s*\('),
    re.compile(r'\bassertFail\s*\('),
    re.compile(r'\bassertInstanceOf\s*\('),
    re.compile(r'\bassertThrow\s*\('),
    re.compile(r'\bassertContains\s*\('),
    re.compile(r'\bassertDeepEquals\s*\('),
    re.compile(r'\bassertStrictEquals\s*\('),
    re.compile(r'\bcheckResult\s*\('),
]


def has_assertion(text):
    if not text:
        return False
    for pattern in ASSERTION_PATTERNS:
        if pattern.search(text):
            return True
    return False

步骤4:收集所有函数定义

这是 R004 规则的核心复杂度所在。需要收集以下所有类型的函数定义:

函数类型示例正则模式
普通函数声明function foo() {(?:function\s+)
async 函数声明async function foo() {(?:async\s+function\s+)
static 方法static foo() {(?:static\s+)
static async 方法static async foo() {(?:static\s+(?:async\s+)?)
非static async 方法async foo() {(?:async\s+)
箭头函数let foo = () => {(?:let|const|var)\s+(\w+)\s*(?::\s*[^=]+)?\s*=\s*(?:async\s*)?\(...\)\s*=>
跨行箭头函数类型注解分多行多行合并匹配
跨行函数声明参数列表分多行full_text 方式查找 {
类方法class 内部的方法extract_class_methods()
def collect_function_definitions(content, filepath):
    """
    收集文件中所有函数定义及其函数体。
    支持:普通函数、async函数、static方法、箭头函数、跨行定义。
    """
    funcs = {}
    lines = content.split('\n')

    for i, line in enumerate(lines):
        # 1. 普通函数 / async函数 / static方法
        m = re.search(
            r'(?:function\s+|static\s+(?:async\s+)?|async\s+function\s+)(\w+)\s*\(',
            line
        )
        if m:
            fname = m.group(1)
            # ⚠️ 关键:使用 full_text 方式查找 {,支持跨行参数声明
            full_text = '\n'.join(lines[i:])
            brace_idx = full_text.find('{', m.end() - m.start())
            if brace_idx == -1:
                continue
            block_end = find_matching_brace(full_text, brace_idx, '{', '}')
            if block_end == -1:
                continue
            body = full_text[brace_idx + 1:block_end]
            funcs[fname] = body
            continue

        # 2. 箭头函数(单行)
        m = re.search(
            r'(?:let|const|var)\s+(\w+)\s*(?::\s*[^=]+)?\s*=\s*(?:async\s*)?\([^)]*(?:\([^)]*\)[^)]*)*\)\s*(?:async\s*)?=>',
            line
        )
        if m:
            fname = m.group(1)
            rest = line[m.end() - 2:]
            full_text = '\n'.join(lines[i:])
            abs_pos = m.end() - 2
            brace_idx = full_text.find('{', abs_pos)
            if brace_idx == -1:
                continue
            block_end = find_matching_brace(full_text, brace_idx, '{', '}')
            if block_end == -1:
                continue
            body = full_text[brace_idx + 1:block_end]
            funcs[fname] = body
            continue

        # 3. 跨行箭头函数(类型注解导致 = 在下一行)
        m = re.search(r'(?:let|const|var)\s+(\w+)\s*:', line)
        if m and line.rstrip().endswith('='):
            fname = m.group(1)
            combined = line
            for j in range(i + 1, min(i + 5, len(lines))):
                combined += ' ' + lines[j].strip()
                # ⚠️ 使用 .+ 替代 [^=]+,兼容类型注解中的 =>
                m2 = re.search(
                    r'(?:let|const|var)\s+\w+\s*:\s*.+=\s*(?:async\s*)?\([^)]*(?:\([^)]*\)[^)]*)*\)\s*(?:async\s*)?=>',
                    combined
                )
                if m2:
                    full_text = '\n'.join(lines[i:])
                    abs_pos = combined.index('=>', m2.start()) + 2
                    brace_idx = full_text.find('{', abs_pos)
                    if brace_idx == -1:
                        break
                    block_end = find_matching_brace(full_text, brace_idx, '{', '}')
                    if block_end == -1:
                        break
                    body = full_text[brace_idx + 1:block_end]
                    funcs[fname] = body
                    break
                if '{' in lines[j]:
                    break

    return funcs

步骤4b:提取类方法

def extract_class_methods(content):
    """
    提取 class 中所有方法的函数体。
    支持 static 方法、非static async 方法。
    """
    methods = {}
    lines = content.split('\n')
    in_class = False
    class_indent = 0

    for i, line in enumerate(lines):
        stripped = line.lstrip()

        # 识别 class 声明
        if re.match(r'(?:export\s+)?(?:default\s+)?class\s+\w+', stripped):
            in_class = True
            class_indent = len(line) - len(stripped)
            continue

        if in_class:
            current_indent = len(line) - len(stripped)
            # class 结束
            if stripped.startswith('}') and current_indent <= class_indent:
                in_class = False
                continue

            if current_indent > class_indent:
                full_text = '\n'.join(lines[i:])

                # static 方法(含 async)
                m = re.search(
                    r'static\s+(?:async\s+)?(\w+)\s*\([^)]*(?:\([^)]*\)[^)]*)*\)',
                    stripped
                )
                if m:
                    fname = m.group(1)
                    # ⚠️ 使用 full_text 方式查找 {,支持跨行参数
                    abs_start = m.start() + stripped.find(m.group(0))
                    brace_idx = full_text.find('{', abs_start)
                    if brace_idx != -1:
                        block_end = find_matching_brace(full_text, brace_idx, '{', '}')
                        if block_end != -1:
                            body = full_text[brace_idx + 1:block_end]
                            methods[fname] = body
                            # 递归收集内部函数
                            inner_funcs = collect_function_definitions(body, "")
                            for inner_name, inner_body in inner_funcs.items():
                                if inner_name not in methods:
                                    methods[inner_name] = inner_body
                    continue

                # ⚠️ 非static async 方法(关键修复)
                m = re.search(r'(?:async\s+)(\w+)\s*\(', stripped)
                if m:
                    fname = m.group(1)
                    abs_start = m.start() + stripped.find(m.group(0))
                    brace_idx = full_text.find('{', abs_start)
                    if brace_idx != -1:
                        block_end = find_matching_brace(full_text, brace_idx, '{', '}')
                        if block_end != -1:
                            body = full_text[brace_idx + 1:block_end]
                            methods[fname] = body
                            inner_funcs = collect_function_definitions(body, "")
                            for inner_name, inner_body in inner_funcs.items():
                                if inner_name not in methods:
                                    methods[inner_name] = inner_body

    return methods

步骤5:递归间接断言检测

核心函数:递归检查函数调用链中是否包含断言。

MAX_RECURSION_DEPTH = 5


def check_function_has_assertion(body, local_funcs, all_known_funcs, visited=None, depth=0):
    """
    递归检查函数体中是否包含直接或间接断言。

    检查顺序(关键):
    1. 直接断言检查
    2. 本地函数调用链
    3. 跨文件函数调用链
    4. try-catch 块检查(最后)

    ⚠️ visited 集合延迟标记:只在确定需要递归时才 add,防止污染。
    """
    if visited is None:
        visited = set()
    if depth > MAX_RECURSION_DEPTH:
        return False

    # 1. 直接断言
    if has_assertion(body):
        return True

    # 2. 本地函数调用链
    for fname, fbody in local_funcs.items():
        key = f"local:{fname}"
        if key in visited:
            continue
        # ⚠️ 延迟标记:先检查再标记
        if not (fname in body and fbody):
            continue
        visited.add(key)
        if check_function_has_assertion(
            fbody, local_funcs, all_known_funcs, visited, depth + 1
        ):
            return True

    # 3. 跨文件函数调用链
    for fname, fbody in all_known_funcs.items():
        key = f"known:{fname}"
        if key in visited:
            continue
        if fname in local_funcs:
            continue
        if not (fname in body and fbody):
            continue
        visited.add(key)
        if check_function_has_assertion(
            fbody, {}, all_known_funcs, visited, depth + 1
        ):
            return True

    # 4. try-catch 块检查(放在最后)
    try_blocks = find_try_catch_blocks(body)
    if try_blocks:
        for tb in try_blocks:
            if not has_assertion(tb['try_content']) and not has_assertion(tb['catch_content']):
                return False
            if has_assertion(tb['try_content']) and has_assertion(tb['catch_content']):
                return True
        return False

    return False

visited 集合延迟标记的重要性

错误做法(会导致visited集合污染):
  visited.add(key)           # ← 在检查之前就标记
  if fname in body and fbody:
      if check_function_has_assertion(fbody, ...):
          return True

正确做法(延迟标记):
  if fname in body and fbody:  # ← 先检查是否需要递归
      visited.add(key)          # ← 确定需要时才标记
      if check_function_has_assertion(fbody, ...):
          return True

误报案例:msSleep 递归调用中遍历 all_known_funcs 时,如果将不在其 body 中的 registerEvent 也标记为 visited,导致回到上层后 registerEvent 被跳过。

步骤6:跨文件 import 解析

IMPORT_CACHE = {}


def parse_imports(content, filepath):
    """
    解析 import 语句,返回 (named_imports, default_import_paths)。

    ⚠️ 必须使用 re.finditer 而非 re.search,支持多个 default import。
    """
    imports = {}
    default_imports = []

    # Named imports: import { foo, bar } from './utils'
    for m in re.finditer(r'import\s+\{([^}]+)\}\s+from\s+["\'](.+?)["\']', content):
        names = [n.strip() for n in m.group(1).split(',')]
        path = m.group(2)
        for name in names:
            imports[name] = path

    # ⚠️ Default imports: import Utils from './Utils'
    # 必须使用 finditer 捕获所有 default import
    for default_m in re.finditer(r'import\s+(\w+)\s+from\s+["\'](.+?)["\']', content):
        default_imports.append(default_m.group(2))

    return imports, default_imports


def resolve_import_file(import_path, current_filepath):
    """
    解析 import 路径为实际文件路径。
    支持相对路径 (./ 和 ../)。
    """
    if import_path.startswith('./') or import_path.startswith('../'):
        base_dir = os.path.dirname(current_filepath)
        resolved = os.path.normpath(os.path.join(base_dir, import_path))
        for ext in ['.test.ets', '.test.ts', '.ets', '.ts']:
            if os.path.exists(resolved + ext):
                return resolved + ext
        if os.path.exists(resolved):
            return resolved
    return None


def get_imported_functions(filepath):
    """
    从 import 的文件中提取所有函数定义。
    使用 IMPORT_CACHE 缓存,避免重复读取。
    """
    if filepath in IMPORT_CACHE:
        return IMPORT_CACHE[filepath]
    if not os.path.exists(filepath):
        return {}

    try:
        with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
            content = f.read()
    except Exception:
        return {}

    funcs = collect_function_definitions(content, filepath)
    methods = extract_class_methods(content)
    funcs.update(methods)

    IMPORT_CACHE[filepath] = funcs
    return funcs

多 default import 支持的重要性

文件中可能同时存在:
  import router from '@ohos.router'    // 第一个 default import
  import Utils from './Utils'           // 第二个 default import

如果使用 re.search,只会捕获第一个 (router),遗漏 Utils。
修复后使用 re.finditer,捕获所有 default import。

步骤7:Try-catch 断言检测

核心原则:如果 it() 块中存在 try-catch,则 try 和 catch 的每个分支都必须包含断言。

def find_try_catch_blocks(body):
    """
    查找函数体中的所有 try-catch 块。

    ⚠️ 关键处理:
    - } catch { 同行模式:当 try 的 } 和 catch { 在同一行时,
      大括号计数会导致互相抵消。必须优先检查此模式。
    """
    try_blocks = []
    lines = body.split('\n')
    i = 0
    while i < len(lines):
        stripped = lines[i].strip()
        if re.match(r'try\s*\{', stripped):
            try_start = i
            brace_count = stripped.count('{') - stripped.count('}')
            j = i + 1
            try_end_line = -1
            while j < len(lines) and brace_count > 0:
                line_j = lines[j].strip()
                # ⚠️ 优先检查 } catch { 同行模式
                if re.match(r'\}\s*catch\s*(?:\([^)]*\))?\s*\{', line_j):
                    try_end_line = j
                    break
                open_count = line_j.count('{')
                close_count = line_j.count('}')
                brace_count += open_count - close_count
                if brace_count == 0:
                    try_end_line = j
                    break
                j += 1

            if try_end_line == -1:
                i = j + 1
                continue

            try_content_end = try_end_line + 1

            # 查找 catch 块
            catch_start = -1
            catch_end = -1

            # 同行 } catch { 模式
            close_catch_m = re.match(
                r'\}\s*catch\s*(?:\([^)]*\))?\s*\{', lines[try_end_line].strip()
            )
            if close_catch_m:
                catch_start = try_end_line
                catch_brace_count = 1
                k = catch_start + 1
                while k < len(lines) and catch_brace_count > 0:
                    catch_brace_count += lines[k].count('{') - lines[k].count('}')
                    k += 1
                catch_end = k
            else:
                # 异行 catch 模式
                scan_j = try_end_line + 1
                while scan_j < len(lines):
                    catch_m = re.match(
                        r'\}\s*catch\s*(?:\([^)]*\))?\s*\{', lines[scan_j].strip()
                    )
                    if catch_m:
                        catch_start = scan_j
                        catch_brace_count = 1
                        k = catch_start + 1
                        while k < len(lines) and catch_brace_count > 0:
                            catch_brace_count += lines[k].count('{') - lines[k].count('}')
                            k += 1
                        catch_end = k
                        break
                    elif re.match(r'\}\s*finally\s*\{', lines[scan_j].strip()):
                        break
                    elif lines[scan_j].strip() == '}':
                        break
                    scan_j += 1

            try_content = '\n'.join(lines[try_start:try_content_end])
            catch_content = ''
            if catch_start != -1 and catch_end != -1:
                catch_content = '\n'.join(lines[catch_start:catch_end])

            try_blocks.append({
                'try_content': try_content,
                'catch_content': catch_content,
                'try_line': try_start,
                'catch_line': catch_start if catch_start != -1 else -1,
            })
            i = max(catch_end if catch_end > 0 else try_content_end, i + 1)
        else:
            i += 1
    return try_blocks

步骤7b:有效断言检测(过滤注释断言)

def has_effective_assertion(text):
    """
    检查文本中是否包含有效(未注释)的断言。
    过滤以 // 开头的行后再检查断言模式。
    """
    if not text:
        return False
    lines = text.split('\n')
    effective_lines = []
    for line in lines:
        stripped = line.strip()
        if stripped.startswith('//'):
            continue
        effective_lines.append(line)
    effective_text = '\n'.join(effective_lines)
    for pattern in ASSERTION_PATTERNS:
        if pattern.search(effective_text):
            return True
    return False

步骤7c:Try-catch 修复建议生成

def analyze_try_catch_suggestion(body, body_start_line, local_funcs, all_known_funcs):
    """
    分析 try-catch 块的断言情况,生成具体修复建议。

    ⚠️ 关键:在生成建议前,先检查整个 body 是否通过函数调用链获得断言覆盖。
    如果 body 有间接断言(如 Utils.registerEvent()),则不应报告 try-catch 缺失。
    """
    try_blocks = find_try_catch_blocks(body)
    if not try_blocks:
        return None

    suggestions = []
    for tb in try_blocks:
        try_has = has_effective_assertion(tb['try_content']) or check_function_has_assertion(
            tb['try_content'], local_funcs, all_known_funcs
        )
        catch_has = False
        if tb['catch_content']:
            catch_has = has_effective_assertion(tb['catch_content']) or check_function_has_assertion(
                tb['catch_content'], local_funcs, all_known_funcs
            )

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
34
Forks
7
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
r004
Source
github.com/openharmonyinsight/openharmony-skills