Python Asteroids on GitHub

I don’t know why I needed a hint for this test, but I’m glad to have had it.

I commented last time that I felt the testing was a bit light, citing this method as one that I didn’t see how to test:

    def create_missile(self, ships):
        if ships and random.random() <= u.SAUCER_TARGETING_FRACTION:
            velocity_adjustment = Vector2(0, 0)
            ship = ships[0]
            degrees = self.angle_to(ship.position)
        else:
            velocity_adjustment = self.velocity
            degrees = random.random() * 360.0
        return self.missile_at_angle(degrees, velocity_adjustment)

GeePaw Hill wrote:

That if is a function returning missile-spec, composed of degrees and velocity. The consequent is a testable method. The alternate, if passed a float, is too. Extract the whole if to take the two floats, call it missle_spec(ships,float,float). It’s testable. Now the body of create_missle() is an untested one-liner, return self.missle_at_angle(self.missle_spec(ships, random.random(), random.random()). (edited)

Let’s try it just as he spoke it. The missile_spec will need to be some kind of structish thing, or just a sequence, since it’s local to what we’re doing here.

PyCharm does the extract like this:

    def create_missile(self, ships):
        degrees, velocity_adjustment = self.missile_spec(ships)
        return self.missile_at_angle(degrees, velocity_adjustment)

    def missile_spec(self, ships):
        if ships and random.random() <= u.SAUCER_TARGETING_FRACTION:
            velocity_adjustment = Vector2(0, 0)
            ship = ships[0]
            degrees = self.angle_to(ship.position)
        else:
            velocity_adjustment = self.velocity
            degrees = random.random() * 360.0
        return degrees, velocity_adjustment

I could proceed from there but let’s do better. I’ll extract two random variables:

    def create_missile(self, ships):
        should_target = random.random()
        if ships and should_target <= u.SAUCER_TARGETING_FRACTION:
            velocity_adjustment = Vector2(0, 0)
            ship = ships[0]
            degrees = self.angle_to(ship.position)
        else:
            velocity_adjustment = self.velocity
            random_angle = random.random()
            degrees = random_angle * 360.0
        return self.missile_at_angle(degrees, velocity_adjustment)

Now I’ll move the random_angle up to the top:

    def create_missile(self, ships):
        should_target = random.random()
        random_angle = random.random()
        if ships and should_target <= u.SAUCER_TARGETING_FRACTION:
            velocity_adjustment = Vector2(0, 0)
            ship = ships[0]
            degrees = self.angle_to(ship.position)
        else:
            velocity_adjustment = self.velocity
            degrees = random_angle * 360.0
        return self.missile_at_angle(degrees, velocity_adjustment)

Now extract the if:

    def create_missile(self, ships):
        should_target = random.random()
        random_angle = random.random()
        degrees, velocity_adjustment = self.missile_spec(random_angle, should_target, ships)
        return self.missile_at_angle(degrees, velocity_adjustment)

    def missile_spec(self, random_angle, should_target, ships):
        if ships and should_target <= u.SAUCER_TARGETING_FRACTION:
            velocity_adjustment = Vector2(0, 0)
            ship = ships[0]
            degrees = self.angle_to(ship.position)
        else:
            velocity_adjustment = self.velocity
            degrees = random_angle * 360.0
        return degrees, velocity_adjustment

I like that PyCharm is perfectly happy returning two things from the function. So am I.

I wish I had reversed the random arguments so I do that:

    def create_missile(self, ships):
        should_target = random.random()
        random_angle = random.random()
        degrees, velocity_adjustment = self.missile_spec(should_target, random_angle, ships)
        return self.missile_at_angle(degrees, velocity_adjustment)

    def missile_spec(self, should_target, random_angle, ships):
        if ships and should_target <= u.SAUCER_TARGETING_FRACTION:
            velocity_adjustment = Vector2(0, 0)
            ship = ships[0]
            degrees = self.angle_to(ship.position)
        else:
            velocity_adjustment = self.velocity
            degrees = random_angle * 360.0
        return degrees, velocity_adjustment

OK, now, as Hill predicts, we can test missile_spec and create_missile doesn’t need it. So to test:

    def test_missile_spec_targeted(self):
        saucer = Saucer(Vector2(100, 110))
        saucer.velocity = Vector2(99, 77)
        ships = [Ship(Vector2(100, 100))]
        should_target = 0.1
        random_angle = None
        degrees, velocity_adjustment = saucer.missile_spec(should_target, random_angle, ships)
        assert velocity_adjustment == Vector2(0, 0)
        assert degrees == -90

    def test_missile_spec_no_ship(self):
        saucer = Saucer(Vector2(100, 110))
        saucer.velocity = Vector2(99, 77)
        ships = []
        should_target = 0.1
        random_angle = 0.5
        degrees, velocity_adjustment = saucer.missile_spec(should_target, random_angle, ships)
        assert velocity_adjustment == saucer.velocity
        assert degrees == 180

    def test_missile_spec_no_dice(self):
        saucer = Saucer(Vector2(100, 110))
        saucer.velocity = Vector2(99, 77)
        ships = [Ship(Vector2(100, 100))]
        should_target = 0.26
        random_angle = 0.5
        degrees, velocity_adjustment = saucer.missile_spec(should_target, random_angle, ships)
        assert velocity_adjustment == saucer.velocity
        assert degrees == 180

I think those cover us pretty well.

So, now the missile_spec “branching logic” is tested. A good thing. Commit: refactor create_missile for testability, and test resulting missile_spec method.

A good afternoon’s work. Thanks, Hill!

See you next time!