Golang 單元測試:有哪些誤區和實踐?

2021-03-02 阿里技術

阿里妹導讀:單元測試作為開發的有力武器,應該在軟體開發的各個流程中發揮它的價值。原始的開發模式(開發完畢,交給測試團隊進行端到端測試)的流程,應該逐步向 devops 的方向轉變。本文是一個轉型的具體實踐過程,以一個實際的業務應用項目為例,介紹了在展開單測實踐過程中遇到的一些常見問題的思考,並著重介紹了幾種 mock 方法,對於一些相對複雜依賴項較多的業務也可以作為借鑑。

文末福利:雲伺服器怎麼選?

測試是保證代碼質量的有效手段,而單元測試是程序模塊兒的最小化驗證。單元測試的重要性是不言而喻的。相對手工測試,單元測試具有自動化執行、可自動回歸,效率較高的特點。對於問題的發現效率,單測的也相對較高。在開發階段編寫單測 case ,daily push daily test,並通過單測的成功率、覆蓋率來衡量代碼的質量,能有效保證項目的整體質量。

單測應該是可重複執行的,對外部的依賴、環境的變化要通過 mock 或其他手段屏蔽掉。在 On the architecture for unit testing[1]中對好的單測有以下描述:很多人不願意寫單測,是因為項目依賴很多,各個函數之間各種調用,不知道如何在一個隔離的測試環境下進行測試。在實踐中我們調研了幾種隔離(mock)的手段。下面進行逐一介紹。本次實踐的工程項目是一個 http(基於 gin 的http 框架) 的服務。以入口的 controller 層的函數為被測函數,介紹下對它的單測過程。下面的函數的作用是根據工號輸出該用戶下的代碼倉庫的 CodeReview 數據。可以看到這個函數作為入口層還是比較簡單的,只是做了一個參數校驗後調用下遊並將結果透出。
func ListRepoCrAggregateMetrics(c *gin.Context) {    workNo := c.Query("work_no")    if workNo == "" {        c.JSON(http.StatusOK, errors.BuildRsp(errors.ErrorWarpper(errors.ErrParamError.ErrorCode, "work no miss"), nil))        return    }    crCtx := code_review.NewCrCtx(c)    rsp, err := crCtx.ListRepoCrAggregateMetrics(workNo)    if err != nil {        c.JSON(http.StatusOK, errors.BuildRsp(errors.ErrorWarpper(errors.ErrDbQueryError.ErrorCode, err.Error()), rsp))        return    }    c.JSON(http.StatusOK, errors.BuildRsp(errors.ErrSuccess, rsp))}

{  "data": {    "total": 10,    "code_review": [      {        "repo": {          "project_id": 1,          "repo_url": "test"        },        "metrics": {          "code_review_rate": 0.0977918,          "thousand_comment_count": 0,          "self_submit_code_review_rate": 0,          "average_merge_cost": 30462.584,          "average_accept_cost": 30388.75        }      }    ]  },  "errorCode": 0,  "errorMsg": "成功"}

方案一:不 mock 下遊, mock 依賴存儲 (不建議)這種方式是通過配置文件,將依賴的存儲都連接到本地(比如 sqlite , redis)。這種方式下遊沒有 mock 而是會繼續調用。
var db *gorm.DBfunc getMetricsRepo() *model.MetricsRepo {    repo := model.MetricsRepo{        ProjectID:     2,        RepoPath:      "/",        FileCount:     5,        CodeLineCount: 76,        OwnerWorkNo:   "999999",    }    return &repo}func getTeam() *model.Teams {    team := model.Teams{        WorkNo: "999999",    }    return &team}func init() {    db, err := gorm.Open("sqlite3", "test.db")    if err != nil {        os.Exit(-1)    }    db.Debug()    db.DropTableIfExists(model.MetricsRepo{})    db.DropTableIfExists(model.Teams{})    db.CreateTable(model.MetricsRepo{})    db.CreateTable(model.Teams{})    db.FirstOrCreate(getMetricsRepo())    db.FirstOrCreate(getTeam())}type RepoMetrics struct {    CodeReviewRate           float32 `json:"code_review_rate"`                ThousandCommentCount     uint    `json:"thousand_comment_count"`           SelfSubmitCodeReviewRate float32 `json:"self_submit_code_review_rate"` }type RepoCodeReview struct {    Repo        repo.Repo   `json:"repo"`    RepoMetrics RepoMetrics `json:"metrics"`}type RepoCrMetricsRsp struct {    Total          int               `json:"total"`    RepoCodeReview []*RepoCodeReview `json:"code_review"`}func TestListRepoCrAggregateMetrics(t *testing.T) {    w := httptest.NewRecorder()    _, engine := gin.CreateTestContext(w)    engine.GET("/api/test/code_review/repo", ListRepoCrAggregateMetrics)    req, _ := http.NewRequest("GET", "/api/test/code_review/repo?work_no=999999", nil)    engine.ServeHTTP(w, req)    assert.Equal(t, w.Code, 200)    var v map[string]RepoCrMetricsRsp    json.Unmarshal(w.Body.Bytes(), &v)    assert.EqualValues(t, 1, v["data"].Total)    assert.EqualValues(t, 2, v["data"].RepoCodeReview[0].Repo.ProjectID)    assert.EqualValues(t, 0, v["data"].RepoCodeReview[0].RepoMetrics.CodeReviewRate)}

上面的代碼,我們沒有對被測代碼做改動。但是在運行 go test 進行測試時,需要指定配置到測試配置。被測項目是通過環境變量設置的。
RDSC_CONF=$sourcepath/test/data/config.yml go test -v -cover=true -coverprofile=$sourcepath/cover/cover.cover ./...

方案二:下遊通過 interface 被 mock(推薦)gomock[2] 是 Golang 官方提供的 Go 語言 mock 框架。它能夠很好的和 Go testing 模塊兒結合,也能用於其他的測試環境中。Gomock 包括依賴庫 gomock 和接口生成工具 mockgen 兩部分,gomock 用於完成樁對象的管理, mockgen 用於生成對應的 mock 文件。
type Foo interface {  Bar(x int) int}func SUT(f Foo) { }ctrl := gomock.NewController(t)    defer ctrl.Finish()    m := NewMockFoo(ctrl)      m.    EXPECT().    Bar(gomock.Eq(99)).    Return(101)SUT(m)

上面的例子,接口 Foo 被 mock。回到我們的項目,在我們上面的被測代碼中是通過內部聲明對象進行調用的。使用 gomock 需要修改代碼,把依賴通過參數暴露出來,然後初始化時。下面是修改後的被測函數:
type RepoCrCRController struct {    c     *gin.Context    crCtx code_review.CrCtxInterface}func NewRepoCrCRController(ctx *gin.Context, cr code_review.CrCtxInterface) *TeamCRController {    return &TeamCRController{c: ctx, crCtx: cr}}func (ctrl *RepoCrCRController)ListRepoCrAggregateMetrics(c *gin.Context) {    workNo := c.Query("work_no")    if workNo == "" {        c.JSON(http.StatusOK, errors.BuildRsp(errors.ErrorWarpper(errors.ErrParamError.ErrorCode, "員工工號信息錯誤"), nil))        return    }    rsp, err := ctrl.crCtx.ListRepoCrAggregateMetrics(workNo)    if err != nil {        c.JSON(http.StatusOK, errors.BuildRsp(errors.ErrorWarpper(errors.ErrDbQueryError.ErrorCode, err.Error()), rsp))        return    }    c.JSON(http.StatusOK, errors.BuildRsp(errors.ErrSuccess, rsp))}

這樣通過 gomock 生成 mock 接口可以進行測試了:
func TestListRepoCrAggregateMetrics(t *testing.T) {     ctrl := gomock.NewController(t)    defer ctrl.Finish()    m := mock.NewMockCrCtxInterface(ctrl)    resp := &code_review.RepoCrMetricsRsp{    }    m.EXPECT().ListRepoCrAggregateMetrics("999999").Return(resp, nil)    w := httptest.NewRecorder()    ctx, engine := gin.CreateTestContext(w)    repoCtrl := NewRepoCrCRController(ctx, m)    engine.GET("/api/test/code_review/repo", repoCtrl.ListRepoCrAggregateMetrics)    req, _ := http.NewRequest("GET", "/api/test/code_review/repo?work_no=999999", nil)    engine.ServeHTTP(w, req)    assert.Equal(t, w.Code, 200)    got := gin.H{}    json.NewDecoder(w.Body).Decode(&got)    assert.EqualValues(t, got["errorCode"], 0)}

方案三:通過 monkey patch 方式 mock 下遊 (推薦)在上面的例子中,我們需要修改代碼來實現 interface 的mock,對於對象成員函數,無法進行 mock。monkey patch 通過運行時對底層指針內容修改的方式,實現對 instance method 的 mock (注意,這裡要求 instance 的 method 必須是可以暴露的)。用 monkey 方式測試如下:
func TestListRepoCrAggregateMetrics(t *testing.T) {    w := httptest.NewRecorder()    _, engine := gin.CreateTestContext(w)    engine.GET("/api/test/code_review/repo", ListRepoCrAggregateMetrics)    var crCtx *code_review.CrCtx    repoRet := code_review.RepoCrMetricsRsp{    }    monkey.PatchInstanceMethod(reflect.TypeOf(crCtx), "ListRepoCrAggregateMetrics",        func(ctx *code_review.CrCtx, workNo string) (*code_review.RepoCrMetricsRsp, error) {            if workNo == "999999" {                repoRet.Total = 0                repoRet.RepoCodeReview = []*code_review.RepoCodeReview{}            }            return &repoRet, nil        })    req, _ := http.NewRequest("GET", "/api/test/code_review/repo?work_no=999999", nil)    engine.ServeHTTP(w, req)    assert.Equal(t, w.Code, 200)    var v map[string]code_review.RepoCrMetricsRsp    json.Unmarshal(w.Body.Bytes(), &v)    assert.EqualValues(t, 0, v["data"].Total)    assert.Len(t, v["data"].RepoCodeReview, 0)}

Go-sqlmock 可以針對接口 sql/driver[3] 進行 mock。它可以不用真實的 db ,而模擬 sql driver 行為,實現強大的底層數據測試。下面是我們採用 table driven[4] 寫法來進行數據相關測試的例子。
package storeimport (    "database/sql/driver"    "github.com/DATA-DOG/go-sqlmock"    "github.com/gin-gonic/gin"    "github.com/jinzhu/gorm"    "github.com/stretchr/testify/assert"    "net/http/httptest"    "testing")type RepoCommitAndCRCountMetric struct {    ProjectID                 uint `json:"project_id"`    RepoCommitCount           uint `json:"repo_commit_count"`    RepoCodeReviewCommitCount uint `json:"repo_code_review_commit_count"`}var (    w      = httptest.NewRecorder()    ctx, _ = gin.CreateTestContext(w)    ret    = []RepoCommitAndCRCountMetric{})func TestCrStore_FindColumnValues1(t *testing.T) {    type fields struct {        g  *gin.Context        db func() *gorm.DB    }    type args struct {        table      string        column     string        whereAndOr []SqlFilter        group      string        out        interface{}    }    tests := []struct {        name      string        fields    fields        args      args        wantErr   bool        checkFunc func()    }{        {            name: "whereAndOr is null",            fields: fields{                db: func() *gorm.DB {                    sqlDb, mock, _ := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))                    rs1 := sqlmock.NewRows([]string{"project_id", "repo_commit_count", "repo_code_review_commit_count"}).FromCSVString("1, 2, 3")                    mock.ExpectQuery("SELECT project_id, sum(commit_count) as repo_commit_count, sum(code_review_commit_count) as repo_code_review_commit_count FROM `metrics_repo_cr` GROUP BY project_id").WillReturnRows(rs1)                    gdb, _ := gorm.Open("mysql", sqlDb)                    gdb.Debug()                    return gdb                },            },            args: args{                table:      "metrics_repo_cr",                column:     "project_id, sum(commit_count) as repo_commit_count, sum(code_review_commit_count) as repo_code_review_commit_count",                whereAndOr: []SqlFilter{},                group:      "project_id",                out:        &ret,            },            checkFunc: func() {                assert.EqualValues(t, 1, ret[0].ProjectID, "project id should be 1")                assert.EqualValues(t, 2, ret[0].RepoCommitCount, "RepoCommitCount id should be 2")                assert.EqualValues(t, 3, ret[0].RepoCodeReviewCommitCount, "RepoCodeReviewCommitCount should be 3")            },        },        {            name: "whereAndOr is not null",            fields: fields{                db: func() *gorm.DB {                    sqlDb, mock, _ := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))                    rs1 := sqlmock.NewRows([]string{"project_id", "repo_commit_count", "repo_code_review_commit_count"}).FromCSVString("1, 2, 3")                    mock.ExpectQuery("SELECT project_id, sum(commit_count) as repo_commit_count, sum(code_review_commit_count) as repo_code_review_commit_count FROM `metrics_repo_cr` WHERE (metrics_repo_cr.project_id in (?)) GROUP BY project_id").                        WithArgs(driver.Value(1)).WillReturnRows(rs1)                    gdb, _ := gorm.Open("mysql", sqlDb)                    gdb.Debug()                    return gdb                },            },            args: args{                table:  "metrics_repo_cr",                column: "project_id, sum(commit_count) as repo_commit_count, sum(code_review_commit_count) as repo_code_review_commit_count",                whereAndOr: []SqlFilter{                    {                        Condition: SQLWHERE,                        Query:     "metrics_repo_cr.project_id in (?)",                        Arg:       []uint{1},                    },                },                group: "project_id",                out:   &ret,            },            checkFunc: func() {                assert.EqualValues(t, 1, ret[0].ProjectID, "project id should be 1")                assert.EqualValues(t, 2, ret[0].RepoCommitCount, "RepoCommitCount id should be 2")                assert.EqualValues(t, 3, ret[0].RepoCodeReviewCommitCount, "RepoCodeReviewCommitCount should be 3")            },        },        {            name: "group is null",            fields: fields{                db: func() *gorm.DB {                    sqlDb, mock, _ := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))                    rs1 := sqlmock.NewRows([]string{"project_id", "repo_commit_count", "repo_code_review_commit_count"}).FromCSVString("1, 2, 3")                    mock.ExpectQuery("SELECT project_id, sum(commit_count) as repo_commit_count, sum(code_review_commit_count) as repo_code_review_commit_count FROM `metrics_repo_cr` WHERE (metrics_repo_cr.project_id in (?))").                        WithArgs(driver.Value(1)).WillReturnRows(rs1)                    gdb, _ := gorm.Open("mysql", sqlDb)                    gdb.Debug()                    return gdb                },            },            args: args{                table:  "metrics_repo_cr",                column: "project_id, sum(commit_count) as repo_commit_count, sum(code_review_commit_count) as repo_code_review_commit_count",                whereAndOr: []SqlFilter{                    {                        Condition: SQLWHERE,                        Query:     "metrics_repo_cr.project_id in (?)",                        Arg:       []uint{1},                    },                },                group: "",                out:   &ret,            },            checkFunc: func() {                assert.EqualValues(t, 1, ret[0].ProjectID, "project id should be 1")                assert.EqualValues(t, 2, ret[0].RepoCommitCount, "RepoCommitCount id should be 2")                assert.EqualValues(t, 3, ret[0].RepoCodeReviewCommitCount, "RepoCodeReviewCommitCount should be 3")            },        },    }    for _, tt := range tests {        t.Run(tt.name, func(t *testing.T) {            cs := &CrStore{                g: ctx,            }            db = tt.fields.db()            if err := cs.FindColumnValues(tt.args.table, tt.args.column, tt.args.whereAndOr, tt.args.group, tt.args.out); (err != nil) != tt.wantErr {                t.Errorf("FindColumnValues() error = %v, wantErr %v", err, tt.wantErr)            }            tt.checkFunc()        })    }}

Aone (阿里內部項目協作管理平臺)提供了類似 travis-ci[5] 的功能:測試服務[6]。我們可以通過創建單測類型的任務或者直接使用實驗室進行單測集成。
mkdir -p $sourcepath/coverRDSC_CONF=$sourcepath/config/config.yaml go test -v -cover=true -coverprofile=$sourcepath/cover/cover.cover ./...ret=$?; if [[ $ret -ne 0 && $ret -ne 1 ]]; then exit $ret; fi

增量覆蓋率可以通過 gocov/gocov-xml 轉換成 xml 報告,然後通過 diff_cover 輸出增量報告:
cp $sourcepath/cover/cover.cover /root/cover/cover.coverpip install diff-cover==2.6.1gocov convert cover/cover.cover | gocov-xml > coverage.xmlcd $sourcepathdiff-cover $sourcepath/coverage.xml --compare-branch=remotes/origin/develop > diff.out

[1]https://thomasvilhena.com/2020/04/on-the-architecture-for-unit-testing

[2]https://github.com/golang/mock

[3]https://godoc.org/database/sql/driver

[4]https://github.com/golang/go/wiki/TableDrivenTests

[5]https://travis-ci.org/

[6]https://help.aliyun.com/document_detail/64021.html

阿里雲開發者成長計劃面向全年齡段開發者,依託免費資源、免費體驗、免費學習、免費實踐 4 大場景,全面助力開發者輕鬆掌握雲上技能。開發者專屬的特價雲伺服器,涵蓋ECS、MySQL、Flink等多個爆款,低至1元起!

識別下方二維碼,或點擊 「閱讀原文」 ,快去優惠購買吧~

相關焦點

  • Golang後臺單元測試實踐
    Why單元測試新功能的增加,代碼複雜性的提高,優化代碼的需要,或新技術的出現都會導致重構代碼的需求。在沒有寫單元測試的情況下,對代碼進行大規模修改,是一件不敢想像的事情,因為寫錯的概率實在太大了。而如果原代碼有單元測試,即使修改了代碼單測依然通過,說明沒有破壞程序正確性,一點都不慌!
  • Task12: 單元測試
    11.單元測試本節代碼樣例見code/utest文件夾在日常開發中,我們通常需要針對現有的功能進行單元測試,以驗證開發的正確性。在go標準庫中有一個叫做testing的測試框架,可以進行單元測試,命令是go test xxx。測試文件通常是以xx_test.go命名,放在同一包下面。
  • golang 基準測試和性能測試總結
    1、測試類型在*_test.go文件中有三種類型的函數,單元測試函數、基準測試函數和示例函數。
  • Android單元測試實踐
    為什麼要引入單元測試  一般來說我們都不會寫單元測試,為什麼呢?因為要寫多餘的代碼,而且還要進行一些學習,入門有些門檻,所以一般在工程中都不會寫單元測試。那麼為什麼我決定要寫單元測試。  這篇文章看完並不會讓你完全掌握單元測試,但是會給你在單元測試的開始有一個好的指引  大大提高工作效率  單元的概念比較模糊,可以是一個方法,可以是一個時機,但是不是一整套環節,一整套環節那就是集成測試了。為什麼說大大提高了工作效率。
  • 聊聊單元測試
    還有一種情況是,寫代碼的時候並沒有考慮這代碼要怎麼測,因此寫完了以後發現寫單元測試很難,沒有現成的測試入口。這時候項目交付的deadline又快到了,唉,要不先放著改天再寫吧。當然我們都知道,這個改天大概率再也不會做。我們有一萬個理由可以不做單元測試。但是這就好比,組裝一架飛機不用測試各個零件的運作是否符合預期,直接讓它飛起來再看有哪些問題。
  • 單元測試的藝術
    以前也讀過關於單元測試、測試驅動開發的書,也採用了相關的方法實踐了一段時間,但就是因為缺乏對測試的系統了解,所以一開始著急著快速編碼完成任務,就把測試放在一邊了
  • Golang指南:頂級Golang框架、IDE和工具列表
    (點擊尾部閱讀原文前往)原文:https://dzone.com/articles/golang-guide-a-list-of-top-golang-frameworks-ides自推出以來,Google的Go程式語言(Golang)越來越受主流用戶的歡迎。
  • Golang 性能分析工具簡要介紹
    接下來開始介紹比較枯燥的基礎知識了,要注意聽啦~pprof 提供了三種方式的使用Benchmark基於基準測試的 pprof,對於已經寫好的算法包來說,可以利用基準測試和 pprof 來校驗算法是否高效、內存消耗是否合理。
  • TDD測試驅動開發的實踐心得
    而TDD是唯一可以解決和改善這個問題的方式,但可惜的是,我發現國內大部分程式設計師壓根不來這一套,很多程式設計師自己都認同一個觀點:編寫單元測試,會延長功能完成所需要的時間雖然我認為這些程式設計師很可能壓根沒有實施過,是僅憑感覺這麼說的。
  • 基於Vue的Jest單元測試入門與實踐
    介紹Vue-Test-Utils 是 Vue.js 官方的單元測試實用工具庫,它提供了一系列的 API 來使得我們可以很便捷的去寫 Vue 應用中的單元測試。主流的單元測試運行器有很多,比如 Jest、Mocha 和 Karma 等,這幾個在 Vue-Test-Utils 文檔裡都有對應的教程,這裡我們只介紹 Vue-Test-Utils + Jest 結合的示例。❝Jest 是一個由 Facebook 開發的測試框架。Vue 對其進行描述:是功能最全的測試運行器。
  • Golang中的測試相關問題總結
    單元測試是針對任意一個具體的函數而言,無論是一個已導出的函數接口,或者是一個並不導出的內部工具函數,你可以針對這個函數做一組測試,目的在於證明該函數的功用與其所宣稱的相同。由於針對的測試目標是一個小型的代碼單元(例如一個函數),所以也就得名為單元測試。
  • Android單元測試——初探
    單元測試的測試相對於集成測試的測試成本較低單元測試相對於集成測試有運行時間短、投入成本低的優勢即Test Pyramid理論:從上圖可以看出單元測試,測試速度快投入成本少因此我們要將大部分精力投放在單元測試中,保證單元測試的質量之後再進行集成測試與UI測試來提高測試效率提高開發效率
  • Spring Boot 的單元測試和集成測試
    學習如何使用本教程中提供的工具,並在 Spring Boot 環境中編寫單元測試和集成測試。1. 概覽 本文中,我們將了解如何編寫單元測試並將其集成在 Spring Boot 環境中。你可在網上找到大量關於這個主題的教程,但很難在一個頁面中找到你需要的所有信息。我經常注意到初級開發人員混淆了單元測試和集成測試的概念,特別是在談到 Spring 生態系統時。
  • golang每日一題(init函數和main函數)
    先說今天做任務的時候遇到一個場景,本地go build編譯成功的項目,在測試環境時報錯
  • 二年級數學第1單元測試,題量較大,注重實踐,附答案
    二年級上冊數學,第一單元長度單位的學習,已經接近尾聲了。這個單元看起來很簡單,但是二年級同學接受起來,並不容易,而且大量的操作題,與生活實踐聯繫較大,在學習中,家長需要密切關注孩子是否學懂了、會測量了、能進行米與釐米的單位換算了。
  • 二年級數學第1單元測試,題量較大,注重實踐,附答案
    二年級上冊數學,第一單元長度單位的學習,已經接近尾聲了。這個單元看起來很簡單,但是二年級同學接受起來,並不容易,而且大量的操作題,與生活實踐聯繫較大,在學習中,家長需要密切關注孩子是否學懂了、會測量了、能進行米與釐米的單位換算了。下面,就以這個單元的測試卷為例,來談一談學習中的側重點。
  • 國慶節期間小學生單元測試卷,二年級語文第三單元測試
    剛剛過去中秋節,國慶節假期也來了,小學生在這個假期裡可以多做一些單元試卷,來檢查一下開學後的一個月自己在哪些方面知識點掌握的還不夠熟練。今天發布的部編版二年級語文上冊第三單元測試卷,供小學生在假期內練習使用。
  • 解讀Android官方MVP項目單元測試
    提起Android單元測試,大多數同學都很感興趣,都認可其重要性,但在實際工作中真正應用的非常少,一方面是各種推廣落地的阻力,比如擔心對開發進度與效率的影響;另一方面也是這方面的官方指引比較少,雖有一些熱門方案,但一直難有真正意義上的最佳實踐。
  • Golang入門教程——map篇
    但是使用起來的方法都差不多,除了Java是通過get方法獲取鍵值之外,C++、Python和golang都是通過方括號獲取的。聲明與初始化golang中的map聲明非常簡單,我們用map關鍵字表示聲明一個map,然後在方括號內填上key的類型,方括號外填上value的類型。
  • 關於單元測試體系結構的一些心得
    靈活性得到了改善,因為開發人員在依賴於測試覆蓋率較高的測試套件來評估其代碼更改的影響時,對重構代碼,升級程序包以及在需要時修改系統行為更有信心。在討論自動化測試時,我也喜歡將風險管理的話題引入對話中。作為首席軟體工程師,風險管理是我工作的重要組成部分,它涉及對開發團隊進行實踐和流程指導,以減少產品技術退化的風險。