0
0
Lập trình
NM

Bảo vệ mã Rust của bạn bằng cách kiểm thử

Đăng vào 7 tháng trước

• 12 phút đọc

Chủ đề:

#programming#rust

Giới thiệu

Trong thế giới phát triển phần mềm, không có lĩnh vực nào quan trọng hơn kiểm thử. Sau khi hoàn thành mã cho một tính năng, nhiều lập trình viên chỉ kiểm tra sơ bộ để xác nhận rằng nó hoạt động, có thể thử một số trường hợp biên, rồi tuyên bố rằng phát triển đã hoàn thành. Khi mã nguồn ngày càng phức tạp, mỗi thay đổi mã trở thành một canh bạc. Việc thêm logic mới là một chuyện, nhưng sửa đổi hoặc tái cấu trúc logic đã có có thể khiến mọi thứ hỏng hóc ở những nơi xa lạ, không thể dự đoán. Đây là lúc giá trị của việc kiểm thử trở nên rõ ràng. Ví dụ, tôi đang làm việc trên một dự án cá nhân lớn. Dự án này đã hoàn thành khoảng 20%, nhưng mã nguồn đã vượt quá 15.000 dòng và bao gồm 259 trường hợp kiểm thử. Giờ đây, sau khi lặp lại trên một crate, tôi phải chạy toàn bộ bộ kiểm thử. Nếu không, không ai có thể đảm bảo rằng những thay đổi của tôi không làm hỏng bất cứ điều gì.

Việc viết mã kiểm thử thường bị coi là công việc nhàm chán. Khó khăn cho bất kỳ ai để duy trì động lực thực hiện điều gì đó không thú vị và không ngay lập tức cần thiết. May mắn thay, chúng ta đang ở trong kỷ nguyên của AI, và việc viết mã kiểm thử chính là vùng thoải mái của các tác nhân lập trình AI. AI có thể nghĩ ra nhiều trường hợp biên mà chúng ta có thể bỏ qua, viết ra một lượng lớn mã kiểm thử lặp đi lặp lại một cách tự động mà không cần nhiều suy nghĩ.

Bài viết này sẽ giới thiệu một số loại kiểm thử phổ biến và các crate liên quan trong Rust. Tuy nhiên, nó sẽ không đi sâu vào chi tiết về cách sử dụng của chúng, vì tôi đã để AI viết cho tôi, và tôi cũng lười để tự tìm hiểu cụ thể cách sử dụng từng crate.

Kiểm thử thuộc tính

Kiểm thử thuộc tính (property testing) đề cập đến việc kiểm tra xem một cái gì đó có thỏa mãn các thuộc tính tương ứng của nó hay không. Mục tiêu kiểm thử là một cấu trúc. Ví dụ, trong mã cây đỏ-đen của tôi, tôi cần kiểm tra xem cây đỏ-đen vẫn thỏa mãn năm thuộc tính của nó sau nhiều vòng thao tác:

rust Copy
use proptest::prelude::*;
use rb_tree::RBTree;

proptest! {
    #[test]
    fn rb_tree(keys in prop::collection::vec(any::<i32>(), 1..=1000)) {
        let mut tree = RBTree::new();
        for key in &keys {
            tree.insert(*key, *key);
            if let Err(e) = tree.validate() {
                panic!("Cây không hợp lệ sau khi chèn ban đầu: {}", e);
            }
        }

        let mut unique_keys: Vec<_> = keys.clone();
        unique_keys.sort();
        unique_keys.dedup();

        for key in &unique_keys {
            assert!(tree.get(key).is_some());
        }

        for (index, key) in unique_keys.iter().enumerate() {
            tree.remove(key);
            if index % 100 == 0 {
                if let Err(e) = tree.validate() {
                    panic!("Cây không hợp lệ sau khi xóa {}: {}", key, e);
                }
            }
        }
    }
}

Kiểm thử thuộc tính có thể được thực hiện bằng cách sử dụng crate proptest. Nó tạo ra một số lượng lớn đầu vào ngẫu nhiên cho hàm kiểm thử, bao trùm càng nhiều trường hợp càng tốt, bởi vì chúng ta không quan tâm đến các giá trị kiểm thử cụ thể, chỉ cần biết cấu trúc có thỏa mãn các thuộc tính cần thiết sau mỗi thao tác hay không.

Kiểm thử vi phân

Kiểm thử vi phân (differential testing) đề cập đến việc kiểm tra xem một cái gì đó có nhất quán với một triển khai tiêu chuẩn khác hay không. Mục tiêu kiểm thử có thể là một cấu trúc hoặc một hàm/phương thức. Lại dùng cây đỏ-đen làm ví dụ, mục đích của cây đỏ-đen là triển khai một tập hợp có thứ tự. Làm thế nào tôi có thể đảm bảo logic của mình là chính xác? Cách đơn giản nhất là so sánh nó với một tập hợp có thứ tự của thư viện tiêu chuẩn như BTreeMap. Bằng cách thực hiện các thao tác giống hệt nhau trên cả hai, nếu đầu ra của chúng giống hệt nhau sau mỗi bước, điều đó cho thấy logic là chính xác.

Tương tự, chúng ta sử dụng proptest để tạo ra một số lượng lớn đầu vào ngẫu nhiên và kết hợp chúng thành các thao tác ngẫu nhiên:

rust Copy
use proptest::prelude::*;
use rb_tree::RBTree;
use std::collections::BTreeMap;

#[derive(Debug, Clone)]
enum Op<K, V> {
    Insert(K, V),
    Remove(K),
}

proptest! {
    #[test]
    fn fast_differential_test(
        ops in prop::collection::vec(prop_oneof![
            (any::<u16>(), any::<u16>()).prop_map(|(k, v)| Op::Insert(k, v)),
            any::<u16>().prop_map(Op::Remove),
        ], 1..2000)
    ) {
        let mut my_tree = RBTree::new();
        let mut std_tree = BTreeMap::new();

        for (i, op) in ops.iter().enumerate() {
            match op {
                Op::Insert(k, v) => {
                    my_tree.insert(k, v);
                    std_tree.insert(k, v);
                },
                Op::Remove(k) => {
                    my_tree.remove(&k);
                    std_tree.remove(&k);
                }
            }

            if i % 100 == 0 {
                if let Err(e) = my_tree.validate() {
                    panic!("Cây không hợp lệ sau khi xóa ở vòng lặp {}: {}", i, e);
                }
            }

            assert_eq!(my_tree.len(), std_tree.len());
        }

        let my_vec: Vec<_> = my_tree.iter().map(|(k, v)| (*k, *v)).collect();
        let std_vec: Vec<_> = std_tree.iter().map(|(k, v)| (*k, *v)).collect();
        assert_eq!(my_vec, std_vec, "Nội dung cuối cùng không khớp với BTreeMap");

        my_tree.validate().expect("Cấu trúc cây cuối cùng không hợp lệ");
    }
}

Kiểm thử snapshot

Thực sự, toàn bộ bài viết này chỉ là một cái cớ để nói về phần này, vì kiểm thử snapshot có thể là một chủ đề tương đối hẹp. Một thời gian trước, khi tôi viết React, các công cụ kiểm thử sẽ kỳ lạ tạo ra một đống tệp snapshot. Tôi không biết chúng để làm gì vào thời điểm đó, vì vậy tôi đã xóa tất cả và cấu hình công cụ không tạo ra chúng. Gần đây, khi viết một trình phân tích JS, quả boomerang từ nhiều năm trước cuối cùng đã quay lại và đánh tôi.

Trong kiểm thử snapshot, chúng ta truyền một giá trị trong lần chạy kiểm thử đầu tiên, tuần tự hóa một snapshot của nó thành một định dạng (như JSON, YAML) và lưu nó dưới dạng tệp cục bộ. Trong tất cả các kiểm thử tiếp theo, giá trị này sẽ được so sánh với tệp hiện có. Nếu so sánh thất bại, kiểm thử sẽ thất bại.

Trong Rust, chúng ta sử dụng insta cho kiểm thử snapshot. Ví dụ, nếu chúng ta muốn kiểm thử một hàm sắp xếp sort, chúng ta có thể viết:

rust Copy
#[test]
fn test() {
  let input = [3, 5, 2, 4, 1];

  insta::assert_json_snapshot!(sort(input));
}

Trong lần chạy đầu tiên, insta sẽ ghi giá trị của sort(input), giả sử nó là [1, 2, 3, 4, 5], vào một tệp có phần mở rộng .snap.new trong thư mục snapshots. Phần tiêu đề của tệp chứa thông tin siêu dữ liệu như vị trí gọi, và nội dung của tệp là JSON [1, 2, 3, 4, 5]. Trong lần chạy đầu tiên, tệp được tạo sẽ có hậu tố .new, và assert sẽ thất bại. Vì vậy, nếu bạn gọi assert_json_snapshot nhiều lần trong một hàm và chạy nó với cargo test, toàn bộ kiểm thử sẽ thất bại tại assertion đầu tiên, và các assertion tiếp theo sẽ không được thực hiện. Do đó, tốt nhất là cài đặt cargo-insta và chạy các kiểm thử với cargo insta test. Điều này sẽ tạo ra tất cả các tệp .snap.new cùng một lúc.

Sau khi tạo, bạn cần chạy cargo insta review để xem xét tất cả các tệp snapshot. Chấp nhận những tệp nào đúng và từ chối những tệp không đúng, sau đó sửa logic của bạn. Các tệp .snap.new đã được chấp nhận sẽ có hậu tố .new bị xóa, trở thành các tệp .snap, sẽ được sử dụng cho việc so sánh trong các kiểm thử tiếp theo.

Bạn có thể hỏi, điều này có vẻ không hữu ích. Tôi có thể chỉ cần viết:

rust Copy
#[test]
fn test() {
  let input = [3, 5, 2, 4, 1];

  assert_eq!(sort(input), [1, 2, 3, 4, 5]);
}

Tôi thực sự phát hiện ra kiểm thử snapshot vì gần đây tôi đang viết một trình phân tích JS để thực hành, và tôi tự nhiên nghĩ đến việc kiểm thử như thế này:

rust Copy
assert_eq!(
  parse("call(a, b)").to_json(),
  r#"{
  "type": "CallExpression",
  "start": 0,
  "end": 9,
  "callee": {
    "type": "Identifier",
    "start": 0,
    "end": 3,
    "name": "add"
  },
  "arguments": [
    {
      "type": "Identifier",
      "start": 4,
      "end": 5,
      "name": "a"
    },
    {
      "type": "Identifier",
      "start": 7,
      "end": 8,
      "name": "b"
    }
  ],
  "optional": false,
}"#
);

Ngay cả một CallExpression đơn giản cũng tạo ra nhiều văn bản như vậy. Đối với một AST hơi phức tạp hơn, thật khó để viết bằng tay, chưa nói đến việc nhờ AI viết nó.

Kiểm thử snapshot được thiết kế đặc biệt để giải quyết vấn đề này. Đối với các trường hợp kiểm thử mà không thể viết bằng tay, nó cho phép chúng tự động được tạo ra. Bạn chỉ cần đảm bảo rằng lần tạo đầu tiên là chính xác.

Câu hỏi tiếp theo của bạn nên là: Tôi cần viết kiểm thử chính xác vì tôi không biết liệu mã của mình có chính xác hay không, nhưng giờ bạn yêu cầu tôi đảm bảo rằng lần tạo đầu tiên là chính xác. Đây là một cái thế bế tắc cổ điển và là vấn đề lớn nhất với kiểm thử snapshot. Mặc dù insta cung cấp khả năng xem xét, nhưng tôi tin rằng bạn (và tôi) không có đủ kiên nhẫn để xem xét chúng, vì các snapshot này thường rất lớn.

Không có giải pháp hoàn hảo cho vấn đề này. Thông thường, nó được kết hợp với các phương pháp kiểm thử khác, như thêm một số assertion trước để đảm bảo các điểm chính là đúng. Tất nhiên, kịch bản cụ thể của tôi rất dễ kiểm thử. Khi tạo snapshot ban đầu, cho mỗi đoạn mã JS đầu vào, tôi khởi động một quá trình Node.js, truyền mã JS và AST được tạo ra từ Rust cho nó. Node sau đó sử dụng Babel để tạo ra một AST Babel, và tôi so sánh hai cái với nhau để kiểm tra tính nhất quán. Vì vậy, mã của tôi có một "mánh lới" bên ngoài cho bài kiểm thử ban đầu. Về lý thuyết, tôi có thể hoàn toàn từ bỏ kiểm thử snapshot và chỉ so sánh với Babel trong các bài kiểm tra chính thức, nhưng việc tạo một quá trình Node.js tốn kém khá nhiều. So với việc sử dụng snapshot, việc này có chi phí hiệu suất nhỏ hơn rất nhiều.

Tóm tắt

Bài viết này đã giới thiệu ba phương pháp kiểm thử:

  • Kiểm thử thuộc tính: Kiểm tra xem một cấu trúc có duy trì các thuộc tính nhất định trong mọi thời điểm hay không.
  • Kiểm thử vi phân: Kiểm tra xem đầu ra của một cấu trúc hoặc hàm có nhất quán với một triển khai tiêu chuẩn hay không.
  • Kiểm thử snapshot: So sánh với một snapshot được tạo ra trong lần chạy đầu tiên, thường được sử dụng khi giá trị mong đợi khó viết bằng tay.

Với sự bảo vệ của các bài kiểm thử khác nhau, chúng ta có thể cải tiến các tính năng hiện có một cách tự tin, dẫn đến sự cải thiện đáng kể về hiệu suất phát triển. Đặc biệt trong thời đại AI này, AI có thể giúp chúng ta viết bất kỳ trường hợp kiểm thử nào, cung cấp một cách bảo vệ tự nhiên. Và, sự hài lòng khi thấy cargo nextest r chạy thành công mạnh mẽ hơn nhiều so với cargo build!

Gợi ý câu hỏi phỏng vấn
Không có dữ liệu

Không có dữ liệu

Bài viết được đề xuất
Bài viết cùng tác giả

Bình luận

Chưa có bình luận nào

Chưa có bình luận nào