Skip to content

Proposal: mocking support #500

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
PavelVozenilek opened this issue Sep 26, 2017 · 10 comments
Closed

Proposal: mocking support #500

PavelVozenilek opened this issue Sep 26, 2017 · 10 comments
Labels
proposal This issue suggests modifications. If it also has the "accepted" label then it is planned.
Milestone

Comments

@PavelVozenilek
Copy link

Definition from Wikipedia:

In a unit test, mock objects can simulate the behavior of complex, real objects and are therefore useful when a real object is impractical or impossible to incorporate into a unit test.

When one tries to do mocking in C++ he usually needs to:

  1. Create abstract base classes (interfaces) for everything that may be mocked.
  2. Place all mockable functionality into subclasses and virtual functions.
  3. Create mechamism to replace mockable parts with mocks (e.g. via dependency injection).

Something what on first sight looks rather simple turns into nightmare. Design needs changes to allow mocking, internal details have to be made public, complex frameworks are invented to support mocking.


My proposal: if a test needs to mock something just redefine that thing inside the test.

test "with mock" 
{
  // "re-define" is context dependent keyword
  re-define fn fopen(name : string) -> int { ... } // dummy replacements
  re-define fn fclose(int handle) { ... }

  // test code opening and closing files, doesn't actually touch the disk
}

Whenever compiler sees a test containing redefinitions it acts as if original functions do not exist anymore and uses redefinitions instead. For code outside this specific test nothing changes.

(I suppose that names are unique in Zig, so no redefinition will be ambiguous.)


Original functionality may still be accessed:

test "with mock" 
{
  re-define fn fopen(name : string) -> int { 
    ...
    var id = ::fopen(...); // leading :: means original definition in this context
    ...
    return id;
  }
  ...
}

Redefined functions may access test local variables as "globals", to communicate intent and results. I believe access to parent's locals is planned feature for inner functions, this is similar.

test "with mock" 
{
  var expected result : int;
  ...
  re-define fn fopen(name : string) -> int { 
    return expected result;
  }
  ...
}

Unit test may need imports to do the redefinitions:

test "with mock" 
{
  import "whatever needed"
  re-define const some_datatype = struct { ... }
  re-define fn some_function(val : some_datatype) { ... }
  ...
}

Anything could be mocked, e.g. constants, struct methods, types ...

test "with mock" 
{
  re-define const TIMEOUT_MILLIS = 0.1;
  ...
}

Implementation: I am not sure whether such feature could be easily implemented. One (not very scalable) way is to do complete project recompilation for every single test with redefinitions, doing replacement on source code level.


Situations for which suggested feature is not appropriate: very complicated mocks that would require lot of redefinitions. These situations would be better handled in traditional way, with design changes.

@hasenj
Copy link

hasenj commented Sep 28, 2017

I think the whole concept of mocking is an artifact of the OOP philosophy which seems to me at odds with the spirit of what zig is trying to be (please correct me if I'm wrong).

If you are writing low level code I would think you are mostly thinking about data and how to manipulate it.

So if you want to make sure your code is testable and reliable, structure it so that the data manipulation is "pure" in that it's independent of any input/output from the environment.

When you are dealing with pure data, there's nothing to "mock". You just create sample data and feed it to the function to test its output.

For example, if you have to parse csv files and need to test your parser, write it so that it receives a string representing the content of the csv file or a line from it, not a file name that it needs to open. Then you can easily write a test on the function because you just pass it a string and there's no Input/Output to mock.

@tiehuis tiehuis added the proposal This issue suggests modifications. If it also has the "accepted" label then it is planned. label Sep 28, 2017
@PavelVozenilek
Copy link
Author

PavelVozenilek commented Sep 28, 2017

@hasenj: I don't think mocking is only OOP thing, it just happened that OOP turned testing (also) into giant monster. Secondly, it is not always possible (or desirable) have pure functional design which would (mostly) not need such a feature.

Say you have the most primitive webserver (could be done in 50 lines of C) and want to test it. Instead of adding many lines which hijack functions like bind/accept etc and duplicate their hidden state you redefine the (well documented) socket API (~6 functions).

What you get:

  1. The 50 lines webserver is not polluted by any test supporting logic. In this case it could dwarf the "real" code.
  2. The checks inside redefined API are for single specific test, thus simple, with hardcoded literals etc. One is not tempted to turn it into general purpose tool.
  3. The test doesn't touch the "real TCP system". (Sockets can get easily exhausted in the system, with funny consequences for web browsers etc.) And this is done without recreating the TCP/IP stack functionality.

@Ilariel
Copy link

Ilariel commented Sep 28, 2017

I'm not so sure whether we would need a separate mocking support instead of just having more advanced facilities for compile time type manipulation. Basically after initial implementation for your mockable type builder function you could just redefine an interface as you see fit. This of course has some implications such as having to know what you might want to mock and you would have to program against that interface.

However as a possible improvement to @PavelVozenilek's suggestion. Instead of using a re-define keyword we'd use the test keyword to redefine functions in test scopes.

test "with mock"  {
  test fn fopen(name : string) -> int { ... } // dummy replacements
  test fn fclose(int handle) { ... }
  test const TIMEOUT_MILLIS = 0.1;

  // test code opening and closing files, doesn't actually touch the disk
}

@andrewrk andrewrk added this to the 0.2.0 milestone Oct 1, 2017
@PavelVozenilek
Copy link
Author

Here's idea how it could be implemented:

  1. Compiler finds out all functions that are mocked.
  2. It generates code for these functions, but every call to them anywhere in the application is indirect call, through pointer to function.
  3. When a test with mock starts these function pointers are updated with new implementation. Afterwards the original pointer is restored.

Notes:

  1. Mocked inlined functions won't be inlined.
  2. Mocked exported functions will export a helper which calls indirectly the expected code.
  3. One cannot easily mock functions in object files and static/dynamic libraries. It may be done via some assembler trickery, but perhaps it isn't worth the trouble.

@hasenj
Copy link

hasenj commented Oct 11, 2017

If a user needs to mock a function, can't they implement this indirection themselves?

Instead of calling fopen, you can call my_fopen which would check a global flag, and if it's set, call my_mock_fopen, else call the default/builtin fopen .. or something of this sort.

@PavelVozenilek
Copy link
Author

@hasenj: that's complicating the design to support testing. In extreme this leads to complex mock frameworks, everything being interface, everything being publicly accessible.

Imagine testing hundred of functions. That would be hundred of flags and three hundred of new names (my_open, my_mock_open, the flag).

With mock proposal above it would be 0 flags, 0 new names and no design changes. You delete a test and no other fix is needed.

@kyle-github
Copy link

@PavelVozenilek, I certainly have used some mocking frameworks in C in the past, but they were very, very limited in scope and used ugly macro tricks. It has been many years so I do not remember details. Much of it relies on LD_LIBRARY_PATH and other hacks.

Since Zig is compiling everything from scratch, I am not sure that you need to do the patching.

@hasenj, @Ilariel, I would see this as possibly extending beyond the use of mocking for tests. The LD_LIBRARY_PATH trick allows some very interesting (if somewhat delicate) replacement to happen. Perhaps something like:

fn override fopen(name : string) -> int { ... }
fn override fclose(int handle) { ... }

Here I used a new keyword override to show that the function definition is being replaced. The requirement would be that a replacement function MUST have exactly the same signature as the function it is replacing. If you allow calling the previous definition within the replacement function, then you can use this to inject all kinds of debugging etc.

Any kind of mocking or function replacement is fairly useless if you need to be able to modify the original code to make it work.

Perhaps there is a way to leverage compile time to get most of this?

comptime const fopen = if(debug == true) { debug_fopen } else { fopen };

Then in the rest of the code in that module when you use fopen you get the debug version (or not if you are in release mode). While this appears possible today, it does not allow for replacing the function everywhere it is called, not just in the current module.

@Ilariel
Copy link

Ilariel commented Oct 11, 2017

@kyle-github, I have to agree that conditional compiling with compile time constants is good enough for most cases and it has always been good enough for my usage as I usually need proper logging data and small tests aren't exactly good enough for that big things.

As a disclaimer I also have to say that I haven't looked at the compiler code or written any tests in Zig.

However given that tests should be their own isolated cases of code which should fail when result isn't what it should be and they aren't exactly something that you export as library code or as real executables I think function/constant replacement in the scope of the tests with test keyword allowing you provide testable behavior with the same signature/type could be reasonable so that we don't add yet another keyword and you could create tests without having to actually modify the actual source code or prepare it for testing since the beginning. As silly as it sounds I think this could be a nice to have feature that isn't a must.

@tentaclius
Copy link

I'm confused with the status of this request. It is closed as completed and has a milestone associated with it, yet seemingly no changes has been made (or I just wasn't able to locate corresponding commit) and there is no description on how the mocking is supposed to work. None of suggested keywords (override/test/re-define) works and there is no mentioning of this in the 0.6.0 release note or language reference. Was it indeed implemented or just closed without any resolution?

@mlugg
Copy link
Member

mlugg commented Aug 29, 2024

GitHub didn't add "completed" vs "not planned" issue closures until fairly recently; all previously closed issues were assumed to be "completed". Any closed issue labeled "proposal" but not "accepted" is a rejected proposal.

@mlugg mlugg closed this as not planned Won't fix, can't repro, duplicate, stale Aug 29, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
proposal This issue suggests modifications. If it also has the "accepted" label then it is planned.
Projects
None yet
Development

No branches or pull requests

8 participants