基于 vue/jest/allure 的前端接口集成测试(上篇)

发表于:2020-9-25 09:22

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:奔跑的狗子    来源:掘金

  前言
  前些时间,应公司业务需求,想在 vue 项目中,利用 jest 框架做一下接口的集成测试
  但是网上一查呢,利用 jest 做接口测试的案例很少(就连前端测试的 jest 项目案例都很少,这里指实际项目,不是 demo 的那种)。而且经过 jest 官方文档的查阅,发现接口测试这一块需要的一些功能要求,光靠 jest 也很难满足(具体见文章 2.1 功能要求)。
  前期一直在 jest 里摸索,想着曲线救国,弯道完成功能实现,但是效果甚微。一直到发现了 allure ( jest-allure )工具,才给了我一些启示和思路,实现了弯道超车。关于网上 allure 的实际案例么,就更少了,就连官方文档就不是特别友好。经过我个人的翻阅各路资料,加上实际踩坑摸索,才总结了下面一套实际的开发案例,个人感觉还是蛮宝贵的,所以拿出来跟大家分享一下。
  第一部分是测试方法和思路设计,跟项目案例无关,只是个人对产品测试的一些总结(这个项目案例应该算灰盒测试了),不感兴趣的同学可以直接跳到第二部分。第二部分是项目案例的功能点拆解,以及每一个功能点的实现思路,方便针对单独的功能点进行查阅。第三部分开始就是项目的完整改造了(因为想尽可能地写完整,所以内容可能会有点长哈^_^)。
  1.测试方法和思路设计
  1.1 灰盒测试
  1.1.1 概念
  思想
  基于程序运行时的外部表现,同时结合程序内部逻辑结构来设计测试用例
  方式
  通过执行程序、采集程序路径执行信息、获取外部用户接口结果,来进行测试
  目的
  旨在验证软件满足外部指标,并且对软件的所有通道或路径都进行了检验
  时机
  执行时机通常在开发者做完白盒测试之后,在功能测试人员进行大规模集成测试之前
  区别
  通过编写代码、调用函数或者封装好的接口进行测试(白盒)
  但无需关心程序内部的实现细节(黑盒)
  1.1.2 方法和步骤
  1)确定程序的所有输入和输出;
  2)确定程序所有状态;
  3)确定程序主路径(主流程);
  4)确定程序的功能(所有分支);
  5)产生试验子功能 X 的输入,这里 X 为众多子功能之一;
  6)制定验证子功能的 X 的输出;
  7)执行测试用例 X 的软件;
  8)检验测试用例 X 结果的正确性;
  9)对其余子功能,重复(7)和(8);
  重复(4)~(8),然后再进行(9),进行回归测试。
  1.2 测试思路设计
  1.2.1 前置操作
  根据场景生成对应数据,不深入测试
  1.2.2 场景提炼
  根据流程场景划分测试用例
  执行对当前流程有影响的重要分支操作
  对当前流程有影响的参数,同时进行不合规测试
  当前用例不通过的,直接跳过后面的用例
  1.2.3 断言
  只断言对业务流程有影响的字段的值
  1.2.4 后置操作
  状态归位,释放资源,不深入测试
  1.3 一些测试策略
  互不影响的功能点可以放入同一个用例中进行测试
  互不影响的分支可以放入同一个用例中进行测试,A1 中加入 B1,A2 中加入 B2,A3 中不需要再测试 B1、B2
  基础配置,不参与测试,但需要单独维护一套可用配置,以供流程执行
  以用户常用的操作流程为主设计用例,不需要所有情况全部覆盖,异常测试也只需要执行用户常见的异常
  2.整体思路
  名词说明
  测试文件:指 file.test.js
  测试用例:指 describe
  接口用例:指 it
  2.1 功能要求
  2.1.1 核心功能
  接口用例串行测试(非并行)
  同个测试文件内不同接口用例依次进行测试
  不同测试文件依次进行测试
  前面接口用例生成的数据供后面接口用例使用(同个测试文件的同个测试用例中)
  多次执行某个接口用例
  前置操作、后置操作
  断点调试
  跳过某条接口用例
  比如上一个接口失败,导致此接口所需的必要参数为空,那么此接口用例应直接跳过
  2.1.2 辅助功能
  接口信息展示(包括接口信息,出入参)
  断言展示
  接口用例的优先级分类展示
  接口用例的测试结果分类
  如成功、失败、跳过等结果
  接口用例本身分组
  根据接口用例的用户行为或其他依据进行划分
  环境信息展示
  历史数据统计信息展示
  测试覆盖率展示
  2.1.3 项目代码要求
  API 层执行统一的出入参数据处理,共同服务于业务层和测试层
  将数据层从业务层抽离,共同服务于业务层和测试层
  2.2 实现思路
  2.2.1 接口用例串行测试(非并行)
  同个测试文件内不同接口用例依次进行测试
  在接口用例中使用 return promise; 的形式执行请求
// xxx.test.js
// 会执行完第一个接口用例,并拿到数据后,再执行第二个接口用例
describe('第一个测试用例', ()=>{
    it('第一个接口用例',()=>{
    return new Promise((resolve)=>{
        $http('/xxx.do').then((res)=>{
        expect(res).toBeDefined();
        resolve();
      });
    });
  });
  it('第二个接口用例',()=>{
    return $http('/xxx.do').then((res)=>{
        expect(res).toBeDefined();
    });
  });
});
  注意:用例顺序不可改变,会影响测试结果
  不同测试文件依次进行测试( A 文件执行完才会去执行 B 文件)
//package.json
{
  "script": {
     "test": "jest --runInBand"
  }
}
  使用 jest 指令时增加 --runInBand 参数(具体 npm 指令配置请参考 3.2.1 )
  2.2.2 前面接口用例生成的数据供后面接口用例使用(同个测试文件的同个测试用例中)
  ( describe 的完整封装请参考 4.5.1 ,接口用例的完整调用请参考 4.6.2 )
// xxx.test.js
// 第一个接口用例的出参的值赋给 firstData,并在第二个接口用例中作为入参使用
describe('第一个测试用例', ()=>{
  let firstData;
    it('第一个接口用例',()=>{
    return $http('/xxx.do').then((res)=>{
      expect(res).toBeDefined();
        firstData = res;
    });
  });
  let secondData;
  it('第二个接口用例',()=>{
    let params = {
        data: firstData
    };
    return $http('/xxx.do', params).then((res)=>{
      expect(res).toBeDefined();
        secondData = res;
    });
  });
});
  2.2.3 多次执行某个接口用例
  使用 jest 的全局方法:test.each(table)(name, fn, timeout) 详见 jest 文档 ( it.each 的完整封装请参考 4.5.1,接口用例的完整调用请参考 4.6.2 )
// xxx.test.js
// 会遍历执行接口用例,但是能使用不同的参数,并且能依次执行
describe('第一个测试用例', ()=>{
  // 多次操作的数据
  let times = [
    [1, 10],
    [2, 20],
    [3, 30],
  ]
    it.each(times)('第%i次操作,操作数量为%i', (index, count) => {
    let params = {
        data: count
    };
    return $http('/xxx.do', params).then((res)=>{
      expect(res).toBeDefined();
    });
  });
});
  2.2.4 前置操作、后置操作
  使用 jest 的全局方法:beforeAll / beforeEach、afterAll / afterEach 详见 jest 文档
  注意:
  beforeEach 是针对每一个 test/it 进行一次前置操作,而不是 describe;
  要针对每个 describe 进行前置操作,要在 describe 内部使用 beforeAll;
// xxx.test.js
describe('第一个测试用例', ()=>{
  // 预先生成接口用例所需的数据
  let beforeData;
  beforeAll(()=>{
    return $http('/xxx.do').then((res)=>{
        beforeData = res;
    });
  });
    it('第一个接口用例',()=>{
    let params = {
        data: beforeData
    };
    return $http('/xxx.do', params).then((res)=>{
        expect(res).toBeDefined();
    });
  });
  it('第二个接口用例',()=>{
    let params = {
        data: beforeData
    };
    return $http('/xxx.do', params).then((res)=>{
        expect(res).toBeDefined();
    });
  });
  // 状态归位,资源释放
  afterAll(()=>{
    return $http('/xxx.do');
  });
});
  2.2.5 断点调试
  可以使用 vscode 的 node.js debug terminal 直接调试
  2.2.6 跳过某条接口用例
  新定义 it 方法,在不满足特定条件时不调用 function ( it 的完整封装请参考 4.5.1 ,接口用例的完整调用请参考 4.6.2 )
  这样 it 内的函数体就不会执行
  使用 allure 的 endCase 方法将用例状态手动改为 skipped ( reporter 的完整封装请参考 4.5.1 )
  需要 jest-allure 依赖支持
// jest.config.js 的 setupFilesAfterEnv 属性定义的文件,在此新定义 it 方法
import { skipTest } from '@/utils/testUtils/reporter';

const originIt = global.it;
global.it.custom = (
  desc,
  fn = () => {},
  conditions
) => {
  const handleFn = () => {
    // 如果满足条件,则继续执行回调;如果不满足条件,则不执行回调
    if (conditions) {
      return fn();
    } else {
      // 设置用例的 skipped 状态
      skipTest();
      return;
    }
  };
  originIt(itDesc, handleFn);
};
// reporter.js 测试报告文件
// 设置用例的 skipped 状态
export function skipTest(conditions) {
  // 确保在 it 中执行,否则会报错
  if (reporter.allure.getCurrentSuite() && reporter.allure.getCurrentTest()) {
    if (conditions) {
      // 设置 skipped 状态,添加相应信息
      reporter.allure.endCase('skipped', {
        message: reporter.allure.getCurrentSuite().name
      });
    }
  }
}
// xxx.test.js
// 调用新定义的 it 方法来跳过用例
describe('第一个测试用例', ()=>{
  // 跳过第一个接口用例
    it.custom('第一个接口用例',()=>{
    return $http('/xxx.do').then((res)=>{
        expect(res).toBeDefined();
    });
  }, false);
  // 第二个接口用例不跳过
  it.custom('第二个接口用例',()=>{
    return $http('/xxx.do').then((res)=>{
        expect(res).toBeDefined();
    });
  }, true);
});
  2.2.7 接口信息展示
  使用 allure reporter 的 description 方法输出接口信息( 请求方法的完整封装请参考 4.5.2 )
  需要 jest-allure 依赖支持
  使用 allure reporter 的 addParameter 方法输出参数信息( reporter 的完整封装请参考 4.5.1 )
  需要 jest-allure 依赖支持
// httpService.js 测试层使用的接口请求方法
import { requestReporter, paramsReporter } from '@/utils/testUtils/reporter';

function service(url, params) {
  return new Promise((resolve, reject) => {
    request(
      {
        url: url,
        method: 'post',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(params)
      },
      function (error, res, body) {
        let data = body;
        // 尝试 json 格式转换
        try {
          data = JSON.parse(body);
        } catch (err) {}
        // 输出请求日志
        const requestInfo = JSON.stringify({ url, params, data });
        requestReporter(requestInfo);
        // 输出请求参数
        paramsReporter(params);
        resolve(data);
      }
    );
  });
}
// reporter.js 测试报告文件
/**
 * 请求报告
 * @param {object} requestInfo 请求信息
 */
export function requestReporter(requestInfo) {
  // 确保在 it 中执行,否则会报错
  if (reporter.allure.getCurrentSuite() && reporter.allure.getCurrentTest()) {
    reporter.description(requestInfo);
  }
}
/**
 * 参数报告
 * @param {object|array} params 参数
 */
export function paramsReporter(params) {
  // 确保在 it 中执行,否则会报错
  if (reporter.allure.getCurrentSuite() && reporter.allure.getCurrentTest()) {
    // 如果参数是对象,遍历每个 key 值输出信息;如果是数组,直接输出数组信息
    if (Object.prototype.toString.call(params) === '[object Object]') {
      const labels = Object.keys(params);
      labels.forEach((item) => {
        reporter.addParameter('argument', item, JSON.stringify(params[item]));
      });
    } else if (Object.prototype.toString.call(params) === '[object Array]') {
      reporter.addParameter('argument', 'params', JSON.stringify(params));
    }
  }
}
  2.2.8 断言展示
  重新封装断言的调用方式,每个断言都传入断言信息
  使用 allure reporter 的 startStep、 endStep 方法输出断言信息
  需要 jest-allure 依赖支持
// xxx.test.js
// expectReporter 为新封装的断言方法,使用时传入断言信息
import { expectReporter } from '@/utils/testUtils/reporter';

describe('第一个测试用例', ()=>{
  it('第一个接口用例',()=>{
    return $http('/xxx.do').then((res)=>{
      expectReporter('xxx接口返回数据不能为空', () => {
        expect(res).toBeDefined();
      });
    });
  });
});
// reporter.js 测试报告文件
/**
 * 断言报告
 * @param {string} customMsg 断言信息
 * @param {function} declare 断言
 */
export function expectReporter(customMsg, declare) {
  // 确保在 it 中执行,否则会报错
  if (reporter.allure.getCurrentSuite() && reporter.allure.getCurrentTest()) {
    reporter.startStep(customMsg);
    declare();
    reporter.endStep(Status.Passed);
  } else {
    declare();
  }
}
  2.2.9 接口用例的优先级分类展示
  在新定义的 it 方法中增加优先级分类展示的逻辑( it 的完整封装请参考 4.5.1 ,接口用例的完整调用请参考 4.6.2 )
  使用 allure reporter 的 severity 方法设定接口用例的优先级
  由不重要到重要依次为 trivial 、 minor 、 normal 、 critical 、 blocker
// jest.config.js 的 setupFilesAfterEnv 属性定义的文件,在此新定义 it 方法,加入优先级分类展示的逻辑
import { skipTest, severityReporter } from '@/utils/testUtils/reporter';

const originIt = global.it;
global.it.custom = (
  desc,
  fn = () => {},
  conditions,
  severity = 3
) => {
  const handleFn = () => {
    // 根据 severity 参数传入的值来划分优先级
    if (Number.isInteger(severity) && severity <= 5 && severity >= 1) {
      severityReporter(Number(severity));
    }
    // 如果满足条件,则继续执行回调;如果不满足条件,则不执行回调
    if (conditions) {
      return fn();
    } else {
      // 设置用例的 skipped 状态
      skipTest();
      return;
    }
  };
  originIt(itDesc, handleFn);
};
// reporter.js 测试报告文件
import { Severity } from 'jest-allure/dist/Reporter';
// 优先级枚举,越大越重要
const levelMap = {
  1: Severity.Trivial,
  2: Severity.Minor,
  3: Severity.Normal,
  4: Severity.Critical,
  5: Severity.Blocker
};
/**
 * 设定用例优先级(重要程度)
 * @param {number} level 优先级,1 微不足道,5 非常重要
 */
export function severityReporter(level) {
  // 确保在 it 中执行,否则会报错
  if (reporter.allure.getCurrentSuite() && reporter.allure.getCurrentTest()) {
    reporter.severity(levelMap[level]);
  }
}
// xxx.test.js
// 调用新定义的 it 方法来设置接口用例优先级
describe('第一个测试用例', ()=>{
  // 设置优先级为最不重要
    it.custom('第一个接口用例',()=>{
    return $http('/xxx.do').then((res)=>{
        expect(res).toBeDefined();
    });
  }, true, 1);
  // 设置优先级为最重要
  it.custom('第二个接口用例',()=>{
    return $http('/xxx.do').then((res)=>{
        expect(res).toBeDefined();
    });
  }, true, 5);
});
  2.2.10 接口用例的测试结果分类
  用例结果的五种状态
  passed:接口用例成功
  failed:接口用例失败
  broken:接口请求失败
  skipped:跳过接口用例
  unknown:其他异常情况
  使用 allure 的 endCase 方法手动修改用例的结果状态
  在自定义 it 方法、自定义 it.skip 方法、http 服务封装中调用,分别设置 skipped 和 broken 的结果状态( it 的完整封装请参考 4.5.1,http 服务的完整封装请参考 4.5.2, reporter 的完整封装请参考 4.5.1 )
  其他 failed 状态通过自定义结果分类来过滤和区分
  需要 jest-allure 依赖支持
  自定义的结果分类
  跳过测试:状态【skipped】
  包括根据条件手动跳过,以及使用 it.skip 自动跳过
  请求失败:状态【broken】
  包括请求报错、返回非 200 状态码等(不包括请求超时)
  断言失败:状态【failed】,错误匹配【含 declare 】
  请求超时:状态【failed】,错误匹配【含 Timeout 】
  确切的表达应该是断言超时,但断言一般消耗时间很短,更多是由请求超时导致的断言超时
  代码报错:状态【failed】,错误匹配【不含 Timeout 和 declare 】
  未知错误:状态【unknown】
  其他未识别的异常状态,包括使用 it.only 的跳过文件
  配置自定义结果分类文件 categories.json
  需要 jest-allure 依赖支持
  生成 allure-report 前需要将 categories.json 文件复制到 allure-result 目录下(复制文件的脚本请参考 3.2.1 )
// categories.json 文件(固定命名),自定义结果分类
[
  {
    "name": "跳过测试",
    "matchedStatuses": ["skipped"]
  },
  {
    "name": "未知错误",
    "matchedStatuses": ["unknown"]
  },
  {
    "name": "代码报错",
    "traceRegex": "^(((?!Timeout)(?!declare).)*)$",
    "matchedStatuses": ["failed"]
  },
  {
    "name": "断言失败",
    "traceRegex": ".*declare.*",
    "matchedStatuses": ["failed"]
  },
  {
    "name": "请求失败",
    "matchedStatuses": ["broken"]
  },
  {
    "name": "请求超时",
    "traceRegex": ".*Timeout.*",
    "matchedStatuses": ["failed"]
  }
]
// reporter.js 测试报告文件
import { Status } from 'jest-allure/dist/Reporter';
// 状态枚举
const statusMap = {
  // 通过测试
  passed: Status.Passed,
  // 测试中
  pending: Status.Pending,
  // 手动跳过测试,或使用 skip 跳过测试
  skipped: Status.Skipped,
  // 未通过测试
  failed: Status.Failed,
  // 接口请求失败
  broken: Status.Broken,
  // 使用 only 跳过测试
  unknown: 'unknown'
};
// 设置用例的 skipped 状态
export function skipTest(conditions) {
  // 确保在 it 中执行,否则会报错
  if (reporter.allure.getCurrentSuite() && reporter.allure.getCurrentTest()) {
    if (conditions) {
      // 设置 skipped 状态,添加相应信息
      reporter.allure.endCase('skipped', {
        message: reporter.allure.getCurrentSuite().name
      });
    }
  }
}
/**
 * 当前用例不在测试范围,自动设置 skip
 */
export function autoSkip() {
  // 确保在 it 中执行,否则会报错
  if (reporter.allure.getCurrentSuite() && reporter.allure.getCurrentTest()) {
    reporter.allure.endCase(statusMap['skip'], {
      message: '不在当前测试范围的用例'
    });
  }
}
/**
 * 将测试结果设置为 broken
 */
export function brokenTest() {
  // 确保在 it 中执行,否则会报错
  if (reporter.allure.getCurrentSuite() && reporter.allure.getCurrentTest()) {
    reporter.allure.endCase(statusMap['broken'], {
      message: reporter.allure.getCurrentSuite().name
    });
  }
}
// jest 的 setupFilesAfterEnv 属性定义的文件,在此新定义 it 方法
import { autoSkip } from '@/utils/testUtils/reporter';

const originIt = global.it;
// 自定义 it 方法在跳过接口用例的实现思路中已存在,此处不再重复叙述
global.it.custom = ()=>{};
/**
 * 重写自动跳过逻辑
 * @param {string} desc 描述信息
 */
global.it.skip = (desc) => {
  global.it.custom(desc, () => {
    autoSkip();
    return;
  });
};
// httpService.js 测试层使用的接口请求方法
import { brokenTest } from '@/utils/testUtils/reporter';

function service(url, params) {
  return new Promise((resolve, reject) => {
    request(
      {
        url: url,
        method: 'post',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(params)
      },
      function (error, res, body) {
        let data = body;
        // 尝试 json 格式转换
        try {
          data = JSON.parse(body);
        } catch (err) {}
        if (error || res.statusCode !== 200) {
          // 请求失败时修改用例状态为 broken 
          brokenTest();
          reject(data);
        } else {
            resolve(data);
        }
      }
    );
  });
}
  2.2.11 接口用例本身分组
  使用 allure reporter 的 epic、 feature、 story 三个方法手动对用例进行场景分类( it 的完整封装请参考 4.5.1,接口用例的完整调用请参考 4.6.2  )
  epic、 feature、 story 为三层级联关系的场景
  同一级的相同场景的用例会放在一个组中
  上级场景可以包含多个下级场景,用例可以放置在任意一级的场景下分组下
  需要 jest-allure 依赖支持
// reporter.js 测试报告文件
/**
 * 设定用例的用户场景分组
 * @param {array} scenes 三级场景描述
 */
export function behaviorsReporter(scenes = []) {
  if (reporter.allure.getCurrentSuite() && reporter.allure.getCurrentTest()) {
    if (scenes[0] && scenes[1] && scenes[2]) {
      reporter.epic(scenes[0]).feature(scenes[1]).story(scenes[2]);
    } else if (scenes[0] && scenes[1]) {
      reporter.epic(scenes[0]).feature(scenes[1]);
    } else if (scenes[0]) {
      reporter.epic(scenes[0]);
    }
  }
}
// jest 的 setupFilesAfterEnv 属性定义的文件,在此新定义 it 方法,加入优先级分类展示的逻辑
import { skipTest, severityReporter, behaviorsReporter } from '@/utils/testUtils/reporter';

const originIt = global.it;
global.it.custom = (
  desc,
  fn = () => {},
  conditions,
  severity = 3,
  scenes = []
) => {
  const handleFn = () => {
    // 根据 severity 参数传入的值来划分优先级
    if (Number.isInteger(severity) && severity <= 5 && severity >= 1) {
      severityReporter(Number(severity));
    }
    // 根据 scenes 来划定用例场景分组
    if (Array.isArray(scenes)) {
      behaviorsReporter(scenes);
    }
    // 如果满足条件,则继续执行回调;如果不满足条件,则不执行回调
    if (conditions) {
      return fn();
    } else {
      // 设置用例的 skipped 状态
      skipTest();
      return;
    }
  };
  originIt(itDesc, handleFn);
};
// xxx.test.js
// 调用新定义的 it 方法来设置接口用例场景分组
describe('第一个测试用例', ()=>{
  // 设置一级场景
    it.custom('第一个接口用例',()=>{
    return $http('/xxx.do').then((res)=>{
        expect(res).toBeDefined();
    });
  }, true, 1, ['第一个测试用例']);
  // 设置两级场景
  it.custom('第二个接口用例',()=>{
    return $http('/xxx.do').then((res)=>{
        expect(res).toBeDefined();
    });
  }, true, 5, ['第一个测试用例', '第二个接口用例']);
});
  2.2.12 环境信息展示
  配置自定义环境信息 environment.xml
  需要 jest-allure 依赖支持
  生成 allure-report 前需要将 environment.xml 文件复制到 allure-result 目录下(复制文件的脚本请参考 3.2.1 )
  也可以在接口用例中添加环境信息,需要使用 allure reporter 的 addEnvironment 方法添加
// environment.xml 配置环境信息
<environment>
      <parameter>
        <key>账号</key>
        <value>xxxxxx</value>
      </parameter>
      <parameter>
        <key>服务</key>
        <value>http://xxxxx</value>
      </parameter>
</environment>
// reporter.js 测试报告文件
/**
 * 添加环境信息(展示用)
 * @param {string} label 信息名
 * @param {string} value 信息值
 */
export function addEnvironment(label, value) {
  // 确保在 it 中执行,否则会报错
  if (reporter.allure.getCurrentSuite() && reporter.allure.getCurrentTest()) {
    reporter.addEnvironment(label, value);
  }
}
  2.2.13 历史数据统计信息展示
  需要在执行 jest 测试前,将 allure-report 目录下的 history 文件夹复制到 allure-results 目录下,作为历史数据统计的数据源(复制文件的脚本请参考 3.2.1 )
  需要 jest-allure 依赖支持
  2.2.14 测试覆盖率展示
  使用 jest 指令时增加 --coverage 参数(具体 npm 指令配置请参考 3.2.1 )
//package.json
{
  "script": {
     "test": "jest --coverage"
  }
}

  本文内容不用于商业目的,如涉及知识产权问题,请权利人联系51Testing小编(021-64471599-8017),我们将立即处理
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号