Asteroid Dodger: Unit Testing

beginner Rusttestinggame dev
0 / 0

Your collision module is pure math with no rendering or input dependencies — a perfect first target for Rust’s built-in test framework. By the end of this tutorial you will have a thorough test suite for every function in collision.rs.

Milestones overview

Milestone 1Your First TestsTest Aabb::overlaps, closest_point_on_segment, point_in_polygon
Milestone 2Testing circle_hits_polygonIntegration-style tests, edge cases, test helpers

Prerequisites


Milestone 1 — Your first tests

Milestone 1 of 2

Step 1 — A test module for Aabb::overlaps

Open src/collision.rs and add a test module at the very bottom of the file:

#[cfg(test)]
mod tests {
    use super::*;
    use macroquad::prelude::Vec2;

    #[test]
    fn overlapping_boxes_return_true() {
        let a = Aabb { min: Vec2::new(0.0, 0.0), max: Vec2::new(10.0, 10.0) };
        let b = Aabb { min: Vec2::new(5.0, 5.0), max: Vec2::new(15.0, 15.0) };
        assert!(a.overlaps(&b));
    }

    #[test]
    fn non_overlapping_boxes_return_false() {
        let a = Aabb { min: Vec2::new(0.0, 0.0), max: Vec2::new(10.0, 10.0) };
        let b = Aabb { min: Vec2::new(20.0, 20.0), max: Vec2::new(30.0, 30.0) };
        assert!(!a.overlaps(&b));
    }

    #[test]
    fn edge_touching_boxes_overlap() {
        let a = Aabb { min: Vec2::new(0.0, 0.0), max: Vec2::new(10.0, 10.0) };
        let b = Aabb { min: Vec2::new(10.0, 0.0), max: Vec2::new(20.0, 10.0) };
        assert!(a.overlaps(&b));
    }
}

Run the tests:

cargo test

You should see three passing tests.

How Rust tests work

#[cfg(test)] tells the compiler to only include this module when running cargo test — it is stripped from release builds entirely. #[test] marks individual functions as test cases. assert! checks a boolean is true; assert_eq! checks two values are equal and prints both sides on failure. use super::* imports everything from the parent module so tests can access private items too.

Step 2 — Test the geometry helpers

Still inside mod tests, add tests for closest_point_on_segment — projection onto the middle, clamping past the end, and clamping before the start:

    #[test]
    fn closest_point_projects_onto_middle() {
        let result = closest_point_on_segment(
            Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(5.0, 5.0),
        );
        assert!((result - Vec2::new(5.0, 0.0)).length() < 1e-5);
    }

    #[test]
    fn closest_point_clamps_past_end() {
        let result = closest_point_on_segment(
            Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(20.0, 3.0),
        );
        assert!((result - Vec2::new(10.0, 0.0)).length() < 1e-5);
    }

    #[test]
    fn closest_point_at_start_returns_start() {
        let a = Vec2::new(0.0, 0.0);
        let result = closest_point_on_segment(a, Vec2::new(10.0, 0.0), Vec2::new(-5.0, 0.0));
        assert!((result - a).length() < 1e-5);
    }

These compare floating-point results by checking the distance is below a tiny epsilon (1e-5). Exact equality (assert_eq!) is fragile with floats due to rounding.

Now test point_in_polygon with a 10x10 square centered at the origin:

    #[test]
    fn point_inside_square() {
        let verts = vec![
            Vec2::new(-5.0, -5.0), Vec2::new(5.0, -5.0),
            Vec2::new(5.0, 5.0), Vec2::new(-5.0, 5.0),
        ];
        assert!(point_in_polygon(Vec2::new(0.0, 0.0), Vec2::ZERO, &verts));
    }

    #[test]
    fn point_outside_square() {
        let verts = vec![
            Vec2::new(-5.0, -5.0), Vec2::new(5.0, -5.0),
            Vec2::new(5.0, 5.0), Vec2::new(-5.0, 5.0),
        ];
        assert!(!point_in_polygon(Vec2::new(20.0, 20.0), Vec2::ZERO, &verts));
    }

    #[test]
    fn point_on_edge_counts_as_inside() {
        let verts = vec![
            Vec2::new(-5.0, -5.0), Vec2::new(5.0, -5.0),
            Vec2::new(5.0, 5.0), Vec2::new(-5.0, 5.0),
        ];
        assert!(point_in_polygon(Vec2::new(0.0, -5.0), Vec2::ZERO, &verts));
    }

Run cargo test again — you should now see 9 passing tests.


Milestone 2 — Testing circle_hits_polygon

Milestone 2 of 2

Step 3 — Core circle-polygon cases

circle_hits_polygon combines both helpers — it checks each edge against the circle radius, then falls back to point_in_polygon. Start with a small helper that returns a 100x100 square, then test four scenarios:

    fn big_square() -> Vec<Vec2> {
        vec![
            Vec2::new(-50.0, -50.0), Vec2::new(50.0, -50.0),
            Vec2::new(50.0, 50.0), Vec2::new(-50.0, 50.0),
        ]
    }

    #[test]
    fn circle_inside_polygon() {
        assert!(circle_hits_polygon(Vec2::ZERO, 5.0, Vec2::ZERO, &big_square()));
    }

    #[test]
    fn circle_clearly_outside_polygon() {
        assert!(!circle_hits_polygon(Vec2::new(200.0, 200.0), 5.0, Vec2::ZERO, &big_square()));
    }

    #[test]
    fn circle_touching_edge() {
        assert!(circle_hits_polygon(Vec2::new(50.0, 0.0), 5.0, Vec2::ZERO, &big_square()));
    }

    #[test]
    fn circle_outside_but_radius_reaches_edge() {
        // Center is 3 units outside the right edge, but radius is 5
        assert!(circle_hits_polygon(Vec2::new(53.0, 0.0), 5.0, Vec2::ZERO, &big_square()));
    }

Step 4 — Edge cases and organization

big_square() helped, but the edge-case tests need squares of different sizes. Replace it with a parameterized helper at the top of mod tests (below the imports):

    fn square_vertices(size: f32) -> Vec<Vec2> {
        let h = size / 2.0;
        vec![
            Vec2::new(-h, -h), Vec2::new(h, -h),
            Vec2::new(h, h), Vec2::new(-h, h),
        ]
    }

Helper functions inside the test module are only compiled during testing and don’t need #[test]. Now add edge-case tests:

    #[test]
    fn zero_radius_circle_inside() {
        let verts = square_vertices(100.0);
        assert!(circle_hits_polygon(Vec2::ZERO, 0.0, Vec2::ZERO, &verts));
    }

    #[test]
    fn zero_radius_circle_outside() {
        let verts = square_vertices(100.0);
        assert!(!circle_hits_polygon(Vec2::new(200.0, 0.0), 0.0, Vec2::ZERO, &verts));
    }

    #[test]
    fn polygon_with_two_vertices_is_a_line() {
        let verts = vec![Vec2::new(-10.0, 0.0), Vec2::new(10.0, 0.0)];
        assert!(circle_hits_polygon(Vec2::new(0.0, 2.0), 5.0, Vec2::ZERO, &verts));
        assert!(!circle_hits_polygon(Vec2::new(0.0, 20.0), 5.0, Vec2::ZERO, &verts));
    }

    #[test]
    fn very_large_polygon() {
        let verts = square_vertices(10_000.0);
        assert!(circle_hits_polygon(Vec2::ZERO, 1.0, Vec2::ZERO, &verts));
        assert!(!circle_hits_polygon(Vec2::new(6000.0, 0.0), 1.0, Vec2::ZERO, &verts));
    }

Run the full suite one more time:

cargo test

All 17 tests should pass.

cargo test flags

cargo test —lib runs only unit tests inside your src/ files, skipping doc-tests and integration tests. This is faster when you only care about the code you just changed. Plain cargo test runs everything.

#[should_panic]

If a function is supposed to panic on bad input, mark a test with #[should_panic] and it passes only if the function panics. We don’t need it here — our collision functions handle degenerate inputs gracefully — but it is useful for testing validation code that calls panic! or unwrap() intentionally.


Full test module

Here is the complete #[cfg(test)] block for the bottom of src/collision.rs:

#[cfg(test)]
mod tests {
    use super::*;
    use macroquad::prelude::Vec2;

    fn square_vertices(size: f32) -> Vec<Vec2> {
        let h = size / 2.0;
        vec![
            Vec2::new(-h, -h), Vec2::new(h, -h),
            Vec2::new(h, h), Vec2::new(-h, h),
        ]
    }

    #[test]
    fn overlapping_boxes_return_true() {
        let a = Aabb { min: Vec2::new(0.0, 0.0), max: Vec2::new(10.0, 10.0) };
        let b = Aabb { min: Vec2::new(5.0, 5.0), max: Vec2::new(15.0, 15.0) };
        assert!(a.overlaps(&b));
    }
    #[test]
    fn non_overlapping_boxes_return_false() {
        let a = Aabb { min: Vec2::new(0.0, 0.0), max: Vec2::new(10.0, 10.0) };
        let b = Aabb { min: Vec2::new(20.0, 20.0), max: Vec2::new(30.0, 30.0) };
        assert!(!a.overlaps(&b));
    }
    #[test]
    fn edge_touching_boxes_overlap() {
        let a = Aabb { min: Vec2::new(0.0, 0.0), max: Vec2::new(10.0, 10.0) };
        let b = Aabb { min: Vec2::new(10.0, 0.0), max: Vec2::new(20.0, 10.0) };
        assert!(a.overlaps(&b));
    }
    #[test]
    fn closest_point_projects_onto_middle() {
        let r = closest_point_on_segment(Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(5.0, 5.0));
        assert!((r - Vec2::new(5.0, 0.0)).length() < 1e-5);
    }
    #[test]
    fn closest_point_clamps_past_end() {
        let r = closest_point_on_segment(Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(20.0, 3.0));
        assert!((r - Vec2::new(10.0, 0.0)).length() < 1e-5);
    }
    #[test]
    fn closest_point_at_start_returns_start() {
        let r = closest_point_on_segment(Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(-5.0, 0.0));
        assert!(r.length() < 1e-5);
    }
    #[test]
    fn point_inside_square() {
        assert!(point_in_polygon(Vec2::ZERO, Vec2::ZERO, &square_vertices(10.0)));
    }
    #[test]
    fn point_outside_square() {
        assert!(!point_in_polygon(Vec2::new(20.0, 20.0), Vec2::ZERO, &square_vertices(10.0)));
    }
    #[test]
    fn point_on_edge_counts_as_inside() {
        assert!(point_in_polygon(Vec2::new(0.0, -5.0), Vec2::ZERO, &square_vertices(10.0)));
    }
    #[test]
    fn circle_inside_polygon() {
        assert!(circle_hits_polygon(Vec2::ZERO, 5.0, Vec2::ZERO, &square_vertices(100.0)));
    }
    #[test]
    fn circle_clearly_outside_polygon() {
        assert!(!circle_hits_polygon(Vec2::new(200.0, 200.0), 5.0, Vec2::ZERO, &square_vertices(100.0)));
    }
    #[test]
    fn circle_touching_edge() {
        assert!(circle_hits_polygon(Vec2::new(50.0, 0.0), 5.0, Vec2::ZERO, &square_vertices(100.0)));
    }
    #[test]
    fn circle_outside_but_radius_reaches_edge() {
        assert!(circle_hits_polygon(Vec2::new(53.0, 0.0), 5.0, Vec2::ZERO, &square_vertices(100.0)));
    }
    #[test]
    fn zero_radius_circle_inside() {
        assert!(circle_hits_polygon(Vec2::ZERO, 0.0, Vec2::ZERO, &square_vertices(100.0)));
    }
    #[test]
    fn zero_radius_circle_outside() {
        assert!(!circle_hits_polygon(Vec2::new(200.0, 0.0), 0.0, Vec2::ZERO, &square_vertices(100.0)));
    }
    #[test]
    fn polygon_with_two_vertices_is_a_line() {
        let verts = vec![Vec2::new(-10.0, 0.0), Vec2::new(10.0, 0.0)];
        assert!(circle_hits_polygon(Vec2::new(0.0, 2.0), 5.0, Vec2::ZERO, &verts));
        assert!(!circle_hits_polygon(Vec2::new(0.0, 20.0), 5.0, Vec2::ZERO, &verts));
    }
    #[test]
    fn very_large_polygon() {
        let verts = square_vertices(10_000.0);
        assert!(circle_hits_polygon(Vec2::ZERO, 1.0, Vec2::ZERO, &verts));
        assert!(!circle_hits_polygon(Vec2::new(6000.0, 0.0), 1.0, Vec2::ZERO, &verts));
    }
}

Next steps