一.测试目标及测试任务概括

1.项目背景

本项目是一个基于 AI 的代码仓库搜索引擎,通过使用官方Gitee Open API 实现海量开源仓库数据的自动化采集,核心技术包括 Jieba分词构建倒排索引用于关键词检索、text2vec-base-chinese 大模型 + FAISS 实现语义向量搜索,前端基于 React + MUI构建,后端采用 Spring Boot + Python 双服务架构。搜索流程为"倒排索引精确召回 → 语义向量泛化匹配 → 双路取交集 → 数据库回表",最终向用户提供兼顾速度与相关性的代码仓库搜索体验。

2.系统设计

系统划分为四大层:

①数据采集层:通过 Gitee Open API 自动分页抓取用户/组织维度的仓库信息,包括仓库全名、HTML 访问地址及 README文档内容,支持单次批量 INSERT 持久化至 MySQL。

②索引构建层:包含倒排索引与向量索引。倒排索引基于 Jieba 分词引擎对 README文本进行中文分词,配合停用词过滤后构建磁盘级索引文件。向量索引由 Python 侧独立构建,使用 text2vec-base-chinese 将逐条 README 编码为语义向量,存入 FAISS 索引并建立 ID 映射表,供在线语义搜索调用。

③搜索服务层:核心搜索逻辑位 Spring Boot 的 IndexService,采用"关键词 +语义"双路合并策略——倒排索引按词频评分排序快速出 ID 候选集(上限 2000),Python 服务返回 FAISS 向量相似度排序的 ID列表,两路求交集后通过数据库回表查询完整仓库信息,最终返回前端展示。

④前端交互层:React + Material UI构建单页搜索界面,支持搜索框输入、回车触发、关键词红色高亮、分页浏览及结果链接跳转,通过 http-proxy-middleware代理请求至 Java 后端。

3.技术栈

  • 后端:Spring Boot、MyBatis-Plus、MyBatis、Jieba 分词、text2vec-base-chinese

  • 数据库:MySQL

二.测试相关信息

1.测试配置

类别 配置/版本
硬件 Legion R7000P 
手动测试浏览器 edge 148.0.3967.70 (正式版本) (64 位)
开发工具 IntelliJ IDEA 2025.1.7
自动化测试工具 Selenium 4.21.0
操作系统 Windows 10 家庭版
自动化测试浏览器 chrome  148.0.7778.97(正式版本) (64 位)

2.测试用例

三、自动化测试

1.公共类 Utils

/**
 * 自动化测试基类:封装公共方法(驱动创建、等待、截图)
 * 全局只创建一个 WebDriver 实例,所有测试类共享
 */
public class BaseTest {

    protected static WebDriver driver;
    protected static WebDriverWait wait;
    private static boolean initialized = false;

    protected static final String BASE_URL = "http://localhost:3000";
    protected static final String SCREENSHOT_DIR = "target/screenshots";

    // 直接使用已有驱动,跳过 WebDriverManager 下载
    private static final String CHROME_DRIVER_PATH =
            "C:/Users/Pikaqi/.cache/selenium/chromedriver/win64/148.0.7778.97/chromedriver.exe";

    @BeforeAll
    public static void setUp() {
        if (!initialized) {
            System.setProperty("webdriver.chrome.driver", CHROME_DRIVER_PATH);

            ChromeOptions options = new ChromeOptions();
            options.addArguments("--remote-allow-origins=*");
            options.addArguments("--disable-gpu");
            options.addArguments("--no-sandbox");
            options.addArguments("--disable-dev-shm-usage");

            driver = new ChromeDriver(options);
            driver.manage().window().maximize();

            // 隐式等待:全局最多等10秒
            driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10));

            // 显式等待对象:最长10秒,每500ms轮询一次
            wait = new WebDriverWait(driver, Duration.ofSeconds(10), Duration.ofMillis(500));

            // JVM 退出时自动关闭浏览器
            Runtime.getRuntime().addShutdownHook(new Thread(() -> {
                if (driver != null) {
                    driver.quit();
                }
            }));

            initialized = true;
            System.out.println("=== Chrome Driver 初始化完成 ===");
        }
    }

    @AfterAll
    public static void tearDown() {
        if (driver != null) {
            driver.quit();
            driver = null;
            initialized = false;
            System.out.println("=== Chrome Driver 已关闭 ===");
        }
    }

    // ==================== 等待方法 ====================

    /**
     * 强制等待(线程休眠)
     */
    protected void forceWait(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
    //wait.until是智能等待:它每隔 0.5 秒去检查一次条件是否满足,一旦满足(比如元素出现了),立刻执行下一步

    /**
     * 显式等待:元素可见
     */
    protected WebElement waitForElementVisible(By by) {
        return wait.until(ExpectedConditions.visibilityOfElementLocated(by));
    }

    /**
     * 显式等待:元素可点击
     */
    protected WebElement waitForElementClickable(By by) {
        return wait.until(ExpectedConditions.elementToBeClickable(by));
    }

    /**
     * 显式等待:页面标题包含指定文本
     */
    protected boolean waitForTitleContains(String title) {
        return wait.until(ExpectedConditions.titleContains(title));
    }

    // ==================== 屏幕截图 ====================

    /**
     * 截图保存到 target/screenshots/
     */
    protected void takeScreenshot(String filename) {
        try {
            File src = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE);
            Path dir = Paths.get(SCREENSHOT_DIR);
            if (!Files.exists(dir)) {
                Files.createDirectories(dir);
            }
            String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));
            Path dest = dir.resolve(filename + "_" + ts + ".png");
            Files.copy(src.toPath(), dest);
            System.out.println("截图已保存: " + dest);
        } catch (IOException e) {
            System.err.println("截图失败: " + e.getMessage());
        }
    }
}

2.页面搜索功能

/**
 * 主搜索页面测试用例
 */
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class SearchPageTest extends BaseTest {

    // 搜索结果计数的 XPath:<h6>找到 X 个结果</h6>
    private static final String RESULT_COUNT_XPATH = "//*[@id='root']/div/h6";

    @Test
    @Order(1)
    @DisplayName("T001 - 页面加载:标题与搜索组件存在")
    public void testPageLoad() {
        driver.get(BASE_URL);
        forceWait(1000);

        assertTrue(driver.getTitle().contains("pikaqi"), "页面标题应包含 pikaqi");

        WebElement btn = waitForElementVisible(By.xpath("//button[contains(text(),'搜索')]"));
        assertNotNull(btn, "搜索按钮应存在");

        WebElement input = driver.findElement(By.tagName("input"));
        assertNotNull(input, "搜索输入框应存在");
    }

    @Test
    @Order(2)
    @DisplayName("T002 - 空搜索弹窗:不输入关键词点搜索,提示请输入关键词")
    public void testEmptySearchShowsWarning() {
        driver.get(BASE_URL);
        WebElement btn = driver.findElement(By.xpath("//button[contains(text(),'搜索')]"));
        btn.click();

        // 等待 MUI Snackbar 弹出
        WebElement alert = waitForElementVisible(
                By.xpath("//*[contains(text(),'请输入关键词进行搜索')]"));
        assertNotNull(alert, "空搜索应弹出提示:请输入关键词进行搜索");

        // 等待 Snackbar 入场动画完成再截图
        forceWait(300);
        takeScreenshot("empty_search_warning");
    }

    @Test
    @Order(3)
    @DisplayName("T003 - 正常关键词搜索:结果数大于 0")
    public void testNormalSearch() {
        driver.get(BASE_URL);
        WebElement input = waitForElementVisible(By.tagName("input"));
        input.clear();
        input.sendKeys("Java");

        WebElement btn = driver.findElement(By.xpath("//button[contains(text(),'搜索')]"));
        btn.click();

        // 显式等待结果计数出现
        WebElement countLabel = waitForElementVisible(By.xpath(RESULT_COUNT_XPATH));
        String text = countLabel.getText(); // 格式:"找到 X 个结果"
        int count = Integer.parseInt(text.replaceAll("[^0-9]", ""));
        assertTrue(count > 0, "搜索结果数应大于 0,实际: " + count);

        takeScreenshot("search_java");
    }

    @Test
    @Order(4)
    @DisplayName("T004 - 回车键触发搜索")
    public void testEnterKeySearch() {
        driver.get(BASE_URL);
        WebElement input = waitForElementVisible(By.tagName("input"));
        input.clear();
        input.sendKeys("Python");
        input.sendKeys(Keys.ENTER);

        WebElement countLabel = waitForElementVisible(By.xpath(RESULT_COUNT_XPATH));
        assertNotNull(countLabel, "回车搜索后应显示结果计数");
        String text = countLabel.getText();
        assertTrue(text.contains("找到"), "应显示'找到 X 个结果'");
    }

    @Test
    @Order(5)
    @DisplayName("T005 - 搜索结果包含 Gitee 链接")
    public void testResultContainsGiteeLink() {
        driver.get(BASE_URL);
        WebElement input = waitForElementVisible(By.tagName("input"));
        input.clear();
        input.sendKeys("Spring");
        driver.findElement(By.xpath("//button[contains(text(),'搜索')]")).click();

        // 等待结果加载
        forceWait(2000);

        List<WebElement> links = driver.findElements(By.cssSelector("a[href*='gitee.com']"));
        assumeFalse(links.isEmpty(), "跳过:当前无搜索结果");
        assertTrue(links.get(0).getAttribute("href").contains("gitee.com"),
                "结果链接应指向 Gitee");
    }

    @Test
    @Order(6)
    @DisplayName("T006 - 搜索无结果关键词,计数为 0")
    public void testSearchNoResult() {
        driver.get(BASE_URL);
        WebElement input = waitForElementVisible(By.tagName("input"));
        input.clear();
        input.sendKeys("xyznonexistentkeyword12345");
        driver.findElement(By.xpath("//button[contains(text(),'搜索')]")).click();

        WebElement countLabel = waitForElementVisible(By.xpath(RESULT_COUNT_XPATH));
        String text = countLabel.getText();
        assertTrue(text.contains("0 个结果") || text.contains("0"),
                "无结果时应显示 0,实际: " + text);

        takeScreenshot("search_no_result");
    }

    @Test
    @Order(7)
    @DisplayName("T007 - XSS 防护:搜索 script 标签不触发 alert 弹窗")
    public void testSearchXSSProtection() {
        driver.get(BASE_URL);
        WebElement input = waitForElementVisible(By.tagName("input"));
        input.clear();
        input.sendKeys("<script>alert(1)</script>");
        driver.findElement(By.xpath("//button[contains(text(),'搜索')]")).click();

        forceWait(2000);

        // 尝试切换到原生 JS alert 弹窗,如果存在则说明有 XSS 漏洞
        try {
            driver.switchTo().alert();
            fail("存在 XSS 漏洞:页面弹出了 alert 弹窗");
        } catch (NoAlertPresentException e) {
            // 预期:没有弹窗 = 没有 XSS
        }
    }

    @Test
    @Order(8)
    @DisplayName("T008 - 分页切换:搜索 C语言 结果 > 10 时切换至第 2 页")
    public void testPagination() {
        driver.get(BASE_URL);
        WebElement input = waitForElementVisible(By.tagName("input"));
        input.clear();
        input.sendKeys("C语言");
        driver.findElement(By.xpath("//button[contains(text(),'搜索')]")).click();

        // 等待结果加载
        WebElement countLabel = waitForElementVisible(By.xpath(RESULT_COUNT_XPATH));
        String countText = countLabel.getText();
        int count = Integer.parseInt(countText.replaceAll("[^0-9]", ""));
        assumeFalse(count <= 10, "跳过:结果数不足 10,无需分页");

        // 卡片内的 h6 是仓库名,与顶部的计数 h6 不同
        String firstCardH6 = "(//div[contains(@class,'MuiCard')]//h6)[1]";

        // 记录第 1 页第一个仓库名
        WebElement firstCardTitle = driver.findElement(By.xpath(firstCardH6));
        String page1FirstTitle = firstCardTitle.getText();
        assertFalse(page1FirstTitle.isEmpty(), "第 1 页应有仓库名");

        // 点击分页组件中的第 2 页按钮
        WebElement page2Btn = driver.findElement(By.xpath("//button[@aria-label='Go to page 2']"));
        page2Btn.click();
        forceWait(1000);

        // 验证第 2 页第一个仓库名与第 1 页不同
        WebElement page2FirstTitle = driver.findElement(By.xpath(firstCardH6));
        String page2FirstTitleText = page2FirstTitle.getText();
        assertFalse(page2FirstTitleText.isEmpty(), "第 2 页应有仓库名");
        assertNotEquals(page1FirstTitle, page2FirstTitleText,
                "切换分页后展示内容应不同");

        takeScreenshot("pagination_page2");
    }
}

3.跳转页面

/**
 * 仓库链接跳转页面测试用例
 */
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class LinkNavigationTest extends BaseTest {

    @Test
    @Order(1)  //控制测试执行顺序
    @DisplayName("T001 - 搜索结果链接点击跳转至 Gitee")  //
    public void testLinkJumpsToGitee() {
        driver.get(BASE_URL);

        // 搜索关键词
        WebElement input = waitForElementVisible(By.tagName("input"));
        input.clear();
        input.sendKeys("Java");
        input.sendKeys(Keys.ENTER);

        forceWait(2000);

        // 查找 Gitee 链接
        List<WebElement> links = driver.findElements(By.cssSelector("a[href*='gitee.com']"));
        assumeFalse(links.isEmpty(), "跳过:无搜索结果,无法验证链接跳转");

        String originalWindow = driver.getWindowHandle();
        WebElement firstLink = links.get(0);
        firstLink.click();

        // 切换到新标签页
        forceWait(1000);
        List<String> windows = new ArrayList<>(driver.getWindowHandles());
        assertTrue(windows.size() > 1, "点击链接应打开新标签页");

        driver.switchTo().window(windows.get(1));
        String currentUrl = driver.getCurrentUrl();
        assertTrue(currentUrl.contains("gitee.com"), "跳转后 URL 应包含 gitee.com,实际: " + currentUrl);
        takeScreenshot("gitee_page");
        driver.close();
        driver.switchTo().window(originalWindow);
    }

    @Test
    @Order(2)
    @DisplayName("T002 - 搜索结果链接在新标签页打开")
    public void testLinkOpensInNewTab() {
        driver.get(BASE_URL);

        WebElement input = waitForElementVisible(By.tagName("input"));
        input.clear();
        input.sendKeys("Spring");
        input.sendKeys(Keys.ENTER);

        forceWait(2000);

        List<WebElement> links = driver.findElements(By.cssSelector("a[href*='gitee.com']"));
        assumeFalse(links.isEmpty(), "跳过:无搜索结果,无法验证链接属性");

        String target = links.get(0).getAttribute("target");
        assertEquals("_blank", target, "外部链接 target 应为 _blank");

        String rel = links.get(0).getAttribute("rel");
        assertNotNull(rel, "外部链接应有 rel 属性");
        assertTrue(rel.contains("noopener"), "rel 应包含 noopener,实际: " + rel);
    }
}

四、性能测试

本次测试采用 JMeter 的 Stepping Thread Group(梯度压测线程组)对系统的 搜索接口 进行压力测试。测试重点在于观察系统在高并发递增场景下的性能拐点、吞吐量极限以及响应稳定性。

1.线程组设置

压测模型配置

  • 初始活跃线程:10 个并发线程。

  • 递增步长:每 2 秒增加 2 个线程。

  • 最大并发负载:10 个线程,持续压测 30 秒后逐步释放。

  • 总样本数:累计发送请求 1,078 次。

2.响应时间

 响应时间随负载变化的趋势

  • 低负载阶段(0~13秒):并发线程处于 1~4 个时,接口响应时间极快,保持在 280ms - 340ms 之间。

  • 负载爬升与峰值阶段(13~60秒):随着并发线程逐步爬升并维持在 10 个满载状态,由于服务器 CPU 算力(向量比对与文本分词)以及数据库连接池压力上升,响应时间呈阶梯状缓慢上扬,最终在 480ms - 640ms 之间发生周期性震荡。

  • 释放阶段(60秒后):随着线程释放,响应时间瞬间回落至 280ms 状态。

3.吞吐量

吞吐量(QPS)吞吐能力分析

  • 爬升期:前 20 秒内,随着并发用户数的增加,QPS 呈现出线性上升趋势,从 3/sec 飙升至 18/sec。

  • 饱和期:在 26~60 秒的满载期间,吞吐量死死稳定在 18/sec - 23/sec 之间窄幅震荡。

  • 结论:系统的性能瓶颈大约在 21 QPS 左右。此时系统达到了当前硬件/环境配置下的最大算力输出。

4.聚合报告

指标名称 测试结果值 性能评估
总样本数 (Samples) 1,078 次 样本量充足
平均响应时间 (Average) 463 ms 整体表现优秀,半秒内返回结果
90% 百分位数 585 ms 90% 的用户请求在 0.58 秒内完成
95% 百分位数 602 ms 高并发下长尾延迟控制良好,无严重卡顿
最大响应时间 (Max) 685 ms 最慢的一次请求也在 0.7 秒内,未发生超大延迟
异常率 (Error %) 0.00% 系统表现稳定,无熔断、无报错、无 500
平均吞吐量 (Throughput) 16.1 / sec 系统当前每秒可稳定处理 16.1 次复杂语义搜索

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐