LPGhatguy / lemur

Partial implementation of Roblox API in Lua
MIT License
62 stars 25 forks source link

Easier way to test with shims or mocks #158

Closed DeanPhillipsOKC closed 5 years ago

DeanPhillipsOKC commented 5 years ago

I might be using Lemur, and TestEZ incorrectly, but coming from a C# and Java background where I'm used to unit testing frameworks like NUnit, JUnit, Mockito, and Moq where IoC principles are normally used when developing the units under test, and dependencies are controlled by the instantiator of the class (not the class itself) which makes them easy to mock.

I made a small change to Lemur, that will use the native lua "require" instead of the Lemur habitat implementation, if the parameter is a string. This allows me to load mock implementations from the filesystem, rather than the Lemur habitat.

E.g. Here is a simple "class" that I want to test. Most code has been left out for simplicity, but this module has several dependencies that I don't want to test (e.g., DataStore2)

local PlayerEntity = {}

local dependencies = require(script.Dependencies).Get()

-- Constructor
function PlayerEntity.new(player)
    local p = { 
        Player = player,
    }
    setmetatable(p, PlayerEntity)
    p.coinStore = dependencies.Datastore("coins", player)
    p.poleStore = dependencies.Datastore("poles", player)
    p.backpack = playerBackpack.new(player)
    p.animationController = dependencies.PlayerAnimationController.new(player)
    return p
end

PlayerEntity.__index = PlayerEntity
return PlayerEntity

Instead of explicitly defining dependencies, I use a script called Dependencies.lua that is a child of the PlayerEntity script.

local Dependencies = { }

local injected = nil

function Dependencies.Get()
    return injected or {
        DataStore = require (game.ServerScriptService.lib.datastore2),
        PlayerBackpack = require (script.Parent.Parent.PlayerBackpack),
        PlayerAnimationController = require(script.Parent.Parent.PlayerAnimationController)
    }
end

function Dependencies.Inject(newDependencies)
     injected = newDependencies
end

return Dependencies

This allows the unit test to override the dependencies with ad hoc tables, or scripts from a mock repository.

return function()
    local uutDependencies = require(script.Parent.Dependencies)

    local DataStoreMock = require("DataStore2Mock")
    local PlayersInGame = {}

    setmetatable(PlayersInGame, {})

    uutDependencies.Inject({
        DataStore = DataStoreMock,
        PlayerBackpack = require("PlayerBackpackMock"),
        PlayerAnimationController = require("PlayerAnimationControllerMock")
    })

    local uut = require(script.Parent)

    describe("GetUserId", function()
        it("Should return the player's UserID", function()
            local player = uut.new({ UserId = "Unit Tester 0", Name = "bob"})
            expect(player:GetUserId()).to.equal("Unit Tester 0")
        end)
    end)
end

This works because of a small change I made to Leumur.

environment.require = function(path)
    if type(path) ~= "string" then
        return habitat:require(path)
    else
        return require(path)
    end
end

This will allow the main test script to add mock repository locations to the lua require path. It seems like this would be a good feature for everybody to use (especially those of us used to basing the majority of our testing on IoC principles and mainstream testing tools), assuming that it isn't a huge departure from the vision of Lemur.

Kampfkarren commented 5 years ago

I have interest in this as well. In my private testing suite with Lemur, I already have something to create shims as seen through this test, however LPG has stated he wants to make sure tests that run in Lemur also run in Roblox, thus shimming in the manner of overriding existing behavior is not in the scope of Lemur, is my understanding. This would have to be a separate project. :(

LPGhatguy commented 5 years ago

@DeanPhillipsOKC This concept seems sound to me, but I'm not entirely sure why you need string require! Can you load DataStore2 into your Lemur DataModel and require it as a ModuleScript instead? Then you'd get compatibility with real Roblox without needing to reach outside the world to grab a dependency.

@Kampfkarren I think what you suggested is a bit different. What's suggested here is what I think you should be doing as well. Instead of trying to mock the actual services that you're getting via Roblox APIs, you should write small wrappers that you can dependency inject instead.

Kampfkarren commented 5 years ago

Instead of trying to mock the actual services that you're getting via Roblox APIs, you should write small wrappers that you can dependency inject instead.

I have an issue in my repository that brings this up and all the benefits I'd get that's mentioned everywhere throughout the code, I just haven't gotten around to it yet 😛

Can you load DataStore2 into your Lemur DataModel and require it as a ModuleScript instead?

Sort of? I had to do weird hacks.

DeanPhillipsOKC commented 5 years ago

@DeanPhillipsOKC This concept seems sound to me, but I'm not entirely sure why you need string require! Can you load DataStore2 into your Lemur DataModel and require it as a ModuleScript instead? Then you'd get compatibility with real Roblox without needing to reach outside the world to grab a dependency.

Good point. I think I originally did it that way because I didn't want the mock files getting pulled into studio (typically on other platforms you don't deploy unit test files), but after considering your question, I realize that I am missing out on some extra verification by doing it this way (e.g., the model.json files are being pulled in correctly by Rojo). I'll take a look when I get out of work.

Thank you both for the replies!

DeanPhillipsOKC commented 5 years ago

So I feel dumb now. After getting home, and taking a look at my code, it all came back to me. After struggling early on to get absolute paths (e.g. game.ServerScriptService.lib.DataStore2) to work correctly in Lemur, I convinced myself that Lemur could only accommodate unit tests as long as they use relative pathing in their require statements (e.g., script.Dependencies).

Now that I'm comfortable with the stack I'm using (Roact, Rodux, Roji, TestEZ, and Lemur), I decided to take another stab at the Lemur documentation, and realized that early on, I never set to the parent of my test folder to the lemur service hierarchy. once I did that everything works like a charm!

Thanks, for getting me on the right track. Now, I can get rid of the hack, and enjoy better coverage.

I think if I can cook up a DI framework (along the lines of autofac, or ninject), and get rid of all of that dependency module boilerplate, I'll have some pretty decent test tools. I'll also take the branch with "spies" for a spin, as I love Jasmine spies, and mocking frameworks.

Thank you both for the contributions to this amazing framwork, and the others.

DeanPhillipsOKC commented 5 years ago

Since this was more me not knowing how lemur works, than an actual issue, I went ahead and closed it.