Bước vào Test Driven (Game) Development
TDD
5
Male avatar

phucvin viết ngày 27/08/2015

Test Driven Development (TDD) là một phương pháp có từ nhiều năm trước, các bạn luôn có thể search về nó và tại sao, làm sao để xài nó. Tuần này, mình đã thử dùng nó trong một số tính năng nhỏ của một project game thật, và bài này sẽ nói về kinh nhiệm của mình.

Hệ thống cần test

Hệ thống cần test của mình luôn luôn là một hoặc nhiều class về logic hay hướng về lĩnh vực của game. Ví dụ như thuật toán, cấu trúc dữ liệu, logic đằng sau một UI, tương tác giữa các class logic, …

Combination: một class logic, domain data

internal class Combination
{
    private readonly SimpleStorePure _store;
    private readonly List<string> _requirements;
    private readonly string _result;

    private readonly IReadOnlyList<string> _roRequirements;

    public Combination(SimpleStorePure store, List<string> requirements, string result)
    {
        _store = store;
        _requirements = new List<string>(requirements);
        _result = result;
        _roRequirements = _requirements.AsReadOnly();
    }

    public IReadOnlyList<string> Requirements { get { return _roRequirements; } }

    public string Result { get { return _result; } }

    public bool CanCombine()
    {
        foreach (string item in _requirements.Distinct())
        {
            int countInStore = _store.Inventory.Select(i => string.Equals(i, item)).Count();
            int countInRequirements = _requirements.Select(i => string.Equals(i, item)).Count();
            if (countInStore < countInRequirements)
            {
                return false;
            }
        }

        return true;
    }

    public bool Combine()
    {
        if (CanCombine())
        {
            foreach (string item in _requirements)
            {
                _store.RemoveItemFromInventory(item);
            }
            _store.AddItemToInventory(_result);
            return true;
        }
        else
        {
            return false;
        }
    }
}

Những class này có thể và nên được test bời vì nó quan trọng với game.

Test cho class Combination class ở trên

[TestFixture]
internal class TestCombination
{
    private SimpleStorePure _store;
    private Combination _comb;

    [SetUp]
    public void SetUp()
    {
        _store = new SimpleStorePure();
        _store.AddItemToInventory("ea001");
        _store.AddItemToInventory("ea001");
        _store.AddItemToInventory("ea002");

        _comb = new Combination(_store,
            requirements: new List<string> { "ea001", "ea001", "ea002" },
            result: "ea009");
    }

    [Test]
    public void ReadOnlyRequirementsAreCorrect()
    {
        Assert.AreEqual(3, _comb.Requirements.Count);
        Assert.AreEqual("ea002", _comb.Requirements[2]);
    }

    [Test]
    public void CanCombineIfEnoughItems()
    {
        Assert.True(_comb.CanCombine());
    }

    [Test]
    public void CanNotCombineIfNotEnoughCountOfAnItem()
    {
        _store.RemoveItemFromInventory("ea001");

        Assert.False(_comb.CanCombine());
    }

    [Test]
    public void CombineThenRequirementsAreRemovedFromInventory()
    {
        _comb.Combine();

        Assert.True(!_store.Inventory.Contains("ea001"));
        Assert.True(!_store.Inventory.Contains("ea002"));
    }

    [Test]
    public void CombineThenResultIsAddedToInventory()
    {
        _comb.Combine();

        Assert.True(_store.Inventory.Contains("ea009"));
    }

    [Test]
    public void CombineThenCanNotCombine()
    {
        _comb.Combine();

        Assert.False(_comb.CanCombine());
    }
}

Không nên test những class khác, như tạo UI, tương tác với tài nguyên ngoại bộ (IO, mạng, thời gian, …), tương tác với thư viện hoặc framework của bên thứ 3, bời vì nó rất khó để test (vì những gì gọi là ngoại bộ thì không nằm trong tầm kiểm soát của mình) và đồng thời cũng vô nghĩa. Vì vậy, chúng ta không nên để logic, dữ liệu đặc trưng, thuật toán và trong những class khó-để-test này, những class này nên giao tiếp với một class logic khác và làm theo nó (sẽ nói thêm sau). Nói cách khác, những class khó-để-test phải trở nên ngu ngốc.

Nhưng trong khi hoặc sau khi viết những class logic, chúng ta sẽ mau chóng nhận ra là mình càn giao tiếp với bên ngoài, để nhận thông tin hoặc để nói cho bên ngoài biết cần phải làm gì. Phức tạp lên rồi đó.

Viết hệ thống phức tạp cần test

Với mình hệ thống phức tạp cần test là một class cần những lệ thuộc ngoại bộ. Ví dụ logic cho một một cái nút không thể nhấn được, sau 10 giây sẽ tự động trở thành có thể nhấn được, cái logic này cần phải biết bao nhiêu thời gian đã trôi qua rồi (một tài nguyên ngoại bộ), và có thể làm cho cái nút thật trở nên nhấn được hoặc không nhấn được (một framework ngoại bộ). Đây là cách mình viết nó:

internal class WeirdButtonPure
{
    private float ticked = 0f;

    public WeirdButtonPure()
    {
        setEnable(false);
    }

    internal void tick(float dt)
    {
        if (canStillBeDisabled())
        {
            ticked += dt;
            if (!canStillBeDisabled())
            {
                setEnable(true);
            }
        }
    }

    internal bool canStillBeDisabled()
    {
        return ticked < 10;
    }

    protected virtual void setEnable(bool enable)
    {}
}

Như bạn có thể thấy, logic này chứa hàm tick (thực ra nó là một hàm gọi lại), nó sẽ được gọi bởi UI thật hoặc được gọi trong hàm test bằng cách mô phỏng, nói cho logic bao nhiêu thời gian đã trôi qua, từ đó logic sẽ làm việc của nó.

Viết hàm ngắn, chia các bước ra thành nhiều hàm. Những hàm nhỏ này có thể được test sau. Hàm canStillBeDisabled được tách ra và tái sử dụng trong hàm tick.

Nếu để ý sẽ thấy hàm setEnable không làm gì hết, bởi vì nó sẽ được ghi đè (sẽ nói sau) để làm việc của nó, ví dụ như gọi framework UI ngoại bộ để làm cho cái nút thật trở thành không nhấn được.

Ít riêng tư thôi, hệ thống cần test không nên có quá nhiều hàm riêng hoặc thậm chí biến riêng, bởi vì chúng có thể được gọi hoặc kiểm tra khi test. Tất nhiên nếu bạn theo YANGI (You aren’t gonna need it – Bạn sẽ không cần nó đâu), bạn luôn có thể chuyển các thành viên của class thành không riêng tư khi bạn cân test chúng. Nhưng điều mình muốn nói là: có nhiều test kiểm tra trạng thái của đối tượng thì sẽ tốt hơn.

Đây là cách mình test WeirdButtonPure

[TestFixture]
internal class TestWeirdButtonPure
{
    private MyWeirdButtonPure _btn;

    [SetUp]
    public void SetUp()
    {
        _btn = new MyWeirdButtonPure();
    }

    [Test]
    public void DisableAtFirst()
    {
        Assert.False(_btn._enable);
    }

    [Test]
    public void EnableAfterTick10Seconds()
    {
        _btn.tick(10);

        Assert.True(_btn._enable);
    }

    [Test]
    public void StillDisabledAfterTick2Seconds()
    {
        _btn.tick(2);

        Assert.False(_btn._enable);
    }

    [Test]
    public void BeEnabledOnce()
    {
        _btn.tick(10);
        _btn.tick(1);

        Assert.AreEqual(1, _btn._enableCalls);
    }

    [Test]
    public void BeEnabledOnceEvenLastTickWithZero()
    {
        _btn.tick(10);
        _btn.tick(0);

        Assert.AreEqual(1, _btn._enableCalls);
    }

    [Test]
    public void CanStillBeDisableAfterTick5()
    {
        _btn.tick(5);

        Assert.True(_btn.canStillBeDisabled());
    }
}

Tới bây giờ, class logic của bạn đã được viết xong, nó cũng đã được test bằng cách mô phỏng các lệ thuộc ngoại bộ, như vậy là thật sự tốt rồi. Nhưng bạn sẽ cần dùng nó trong thế giới thật với những lệ thuộc ngoại bộ thật! Có rất nhiều cách (dễ) để làm chuyện này, bạn hoàn toàn tự do để chọn những cách phù hợp nhất.

Giao tiếp giữa class logic và thế giới thật

Còn nhớ class khó-để test mình nói chứ, class đó giao tiếp với các lệ thuộc ngoại bộ (thế giới thật). Bây giờ class logic của mình muốn giao tiếp với thế giới thật, nhưng đồng thời cũng không được như vậy vì nó là logic. Làm sao chuyện này có thể được?

Kế thừa, xài nó và rồi class logic của mình vẫn là một logic nhưng class kế thừa từ nó sẽ là class khó-để-test sử dụng các lệ thuộc ngoại bộ. Khá tốt đấy chứ? Với hàm, hàm ảo và hàm gọi lại dưới dạng hàm, class khó-để-test có thể nói logic làm điều gì đó, trả kết quả (ví dụ sau khi tương tác với các tài nguyên ngoại bộ, …) về cho logic hoặc là được gọi bởi logic để làm điều gì đó.

Class thật

internal class WeirdButton : WeirdButtonPure
{
    // This class will be used to interact with UI framework
    // Code in here is just an example

    private Button _btn;

    public WeirdButton(UiFramework uiFramework)
    {
        _btn = uiFramework.GetButton("weird");

        // Register for time passing with UI framework
        uiFramework.registerUpdate(dt => tick(dt));
    }

    protected override void setEnable(bool enable)
    {
        _btn.SetEnable(enable);
    }
}

Class giả dùng để test ở phần trước

private class MyWeirdButtonPure : WeirdButtonPure
{
    internal bool _enable = true;
    internal int _enableCalls = 0;

    protected override void setEnable(bool enable)
    {
        if (enable)
        {
            ++_enableCalls;
        }
        _enable = enable;
    }
}

Như bạn có thể thấy, hàm ảo setEnable đã được ghi đè để làm công việc của nó khi logic gọi. Hàm tick được gọi bời framework UI hoặc là hàm test để nói cho logic biết chuyện gì đã xảy ra. Đây là sự giao tiếp mình cần.

Nhưng sau một vài phút, mình đã gặp vấn đề với tình huống tổng hợp: logic A chứa logic B, vậy làm cách nào A thật có thể chứa B thật, vì A thật kế thừa logic A vốn dĩ chỉ biết logic B. Câu trả lời là hàm ảo.

internal class SomethingPure
{ }

internal class Something : SomethingPure
{ }

internal class CompositionPure
{
    protected SomethingPure _something;

    public CompositionPure()
    {
        createSomething();
    }

    protected virtual void createSomething()
    {
        _something = new SomethingPure();
    }
}

internal class Composition : CompositionPure
{
    protected override void createSomething()
    {
        _something = new Something();
    }
}

Mình nghĩ là sẽ có thêm vấn đề về sau, nhưng những cách khác cũng có vấn đề. Nên mình sẽ tiếp tục với kế thừa để giao tiếp giữa class logic và class khó-để-test. Một ghi chú cho cả bạn và mình: sử dụng kế thừa, nhưng đừng làm cho nó quá sâu hoặc phức tạp, thay vào đó sử dụng tổng hợp (thêm về điều này có lẽ sẽ ở một bài viết khác).

So với cách code thông thường của mình

Sau khi làm TDD với một vài class, mình đã thử viết lại những lớp đó lại sử dụng cách code thông thường của mình. Và mình chỉ tốn 25-30% thời gian code, 50% số dòng code so với sử dụng TDD.

Cách code thông thường của mình là liệt kê tính năng, (cẩn thận) thiết kế và code cùng một lúc, chạy để test bằng tay và mắt, debug nếu có bug, và lặp lại. Trong những project cũ của mình, như vậy là khá ổn, mình chưa từng bị lỗi lớn nào trong thiết kế và code (thực ra là có, nhưng cho dù có sử dụng TDD cũng không giúp mình tránh lỗi này) . Những lỗi phát sinh trong tương lại cũng có thể được sửa dễ dàng, nhưng đôi lúc mình không test lại nó sau khi sửa xong. Thêm tính năng mới cũng ổn, nhưng nếu tính năng mới gây ảnh hưởng hoặc thay đổi các tính năng cũ thì phải test lại rất nhiều.

Với cách code thông thường, code của mình sẽ sớm trở nên dính và phụ thuộc. Hầu như không có thứ gì có thể được tạo ra mà không phải tạo nguyên cả game. Một vấn đề phức tạp dễ dàng tạo ra mớ code hỗn độn, khó đọc, hiểu, đảm bảo và thay đổi.

Sau khi sử dụng TDD, mình nghĩ nhiều thứ sẽ thay đổi. Mặc dù nó đòi hỏi nhiều thời gian để viết cùng một tính năng, nhưng với code tốt hơn và test, mình tin rằng có thể giảm thời gian cho việc sửa lỗi, bảo trì,mở rộng trong tương lai. Và dẫn tới những sản phẩm tốt hơn trên đường dài.

Có vậy thôi ^.^

Một vài kinh nghiệm của mình với TDD, nó cũng còn khá mới với mình. Mình sẽ tiếp tục chia sẻ các kinh nghiệm với nó sau một vài tuần học hỏi, nghiên cứu và sử dụng.

Bình luận


White
{{ comment.user.name }}
Bỏ hay Hay
{{comment.like_count}}
Male avatar
{{ comment_error }}
Hủy
   

Hiển thị thử

Chỉnh sửa

Male avatar

phucvin

1 bài viết.
1 người follow
Kipalog
{{userFollowed ? 'Following' : 'Follow'}}
Bài viết liên quan
White
10 2
Hôm nay thanh niên Groovy dev này sẽ tiếp tục giới thiệu mấy thứ vui vui của Groovy. Câu chuyện bắt đầu cách đây vài tháng lúc đấy mình vừa vào ct...
Li Nguyen viết gần 2 năm trước
10 2
White
3 2
Với sự ra đời của websocket thì việc giao tiếp hai chiều giữa client và server trở nên dễ dàng hơn bất kỳ lúc nào. Đặc biệt là nếu sử dụng các thư ...
Dang Viet Ha viết gần 2 năm trước
3 2
White
18 6
TDD (Test Driven Development) tức là một phương pháp lập trình chú trọng vào việc test, "viết test trước viết code sau",... rất nhiều người đã thử ...
Huy Trần viết hơn 1 năm trước
18 6
{{like_count}}

kipalog

{{ comment_count }}

bình luận

{{liked ? "Đã kipalog" : "Kipalog"}}


Male avatar
{{userFollowed ? 'Following' : 'Follow'}}
1 bài viết.
1 người follow

 Đầu mục bài viết

Vẫn còn nữa! x

Kipalog vẫn còn rất nhiều bài viết hay và chủ đề thú vị chờ bạn khám phá!