[geary/mjog/unit-test-subproject: 67/69] Move generic unit test classes to a new basically-standalone subproject



commit 6b1bad28b9624a4dc6db6adf2a44889beff47588
Author: Michael Gratton <mike vee net>
Date:   Fri May 8 18:30:35 2020 +1000

    Move generic unit test classes to a new basically-standalone subproject
    
    Break out the generic testing code into something easily re-used, and
    improve the API substantially:
    
     * Use generics to reduce the number of equality tests to effectively
       a single one
     * Make all assert args consistent in that the actual value is always
       listed first.
     * Add convenience API for common string/array/collection assertions

 meson.build                                        |   9 +
 subprojects/vala-unit/COPYING                      | 464 ++++++++++++++++++
 subprojects/vala-unit/README.md                    |   6 +
 subprojects/vala-unit/meson.build                  | 152 ++++++
 subprojects/vala-unit/meson_options.txt            |  12 +
 subprojects/vala-unit/src/async-result-waiter.vala | 108 +++++
 .../vala-unit/src/collection-assertions.vala       | 499 +++++++++++++++++++
 subprojects/vala-unit/src/expected-call.vala       | 148 ++++++
 subprojects/vala-unit/src/mock-object.vala         | 318 ++++++++++++
 subprojects/vala-unit/src/test-adaptor.vala        |  75 +++
 subprojects/vala-unit/src/test-assertions.vala     | 533 +++++++++++++++++++++
 subprojects/vala-unit/src/test-case.vala           | 207 ++++++++
 .../vala-unit/test/collection-assertions.vala      | 216 +++++++++
 subprojects/vala-unit/test/test-assertions.vala    | 299 ++++++++++++
 subprojects/vala-unit/test/test-driver.vala        |  26 +
 test/meson.build                                   |  32 +-
 test/mock-object.vala                              | 442 -----------------
 test/test-case.vala                                | 462 ------------------
 18 files changed, 3078 insertions(+), 930 deletions(-)
---
diff --git a/meson.build b/meson.build
index 3d7f77cff..61d0e0963 100644
--- a/meson.build
+++ b/meson.build
@@ -153,6 +153,15 @@ endif
 # Build glue
 #
 
+vala_unit_proj = subproject(
+  'vala-unit',
+  default_options: [
+    'install=false',
+    'valadoc=@0@'.format(enable_valadoc)
+  ]
+)
+vala_unit_dep = vala_unit_proj.get_variable('vala_unit_dep')
+
 if enable_valadoc
   valadoc = find_program('valadoc')
 endif
diff --git a/subprojects/vala-unit/COPYING b/subprojects/vala-unit/COPYING
new file mode 100644
index 000000000..74e18dbd2
--- /dev/null
+++ b/subprojects/vala-unit/COPYING
@@ -0,0 +1,464 @@
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU Lesser General Public
+License as published by the Free Software Foundation; either
+version 2.1 of the License, or (at your option) any later version.
+
+                 GNU LESSER GENERAL PUBLIC LICENSE
+                      Version 2.1, February 1999
+
+ Copyright (C) 1991, 1999 Free Software Foundation, Inc.
+ 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+[This is the first released version of the Lesser GPL.  It also counts
+ as the successor of the GNU Library Public License, version 2, hence
+ the version number 2.1.]
+
+                           Preamble
+
+  The licenses for most software are designed to take away your
+freedom to share and change it.  By contrast, the GNU General Public
+Licenses are intended to guarantee your freedom to share and change
+free software--to make sure the software is free for all its users.
+
+  This license, the Lesser General Public License, applies to some
+specially designated software packages--typically libraries--of the
+Free Software Foundation and other authors who decide to use it.  You
+can use it too, but we suggest you first think carefully about whether
+this license or the ordinary General Public License is the better
+strategy to use in any particular case, based on the explanations below.
+
+  When we speak of free software, we are referring to freedom of use,
+not price.  Our General Public Licenses are designed to make sure that
+you have the freedom to distribute copies of free software (and charge
+for this service if you wish); that you receive source code or can get
+it if you want it; that you can change the software and use pieces of
+it in new free programs; and that you are informed that you can do
+these things.
+
+  To protect your rights, we need to make restrictions that forbid
+distributors to deny you these rights or to ask you to surrender these
+rights.  These restrictions translate to certain responsibilities for
+you if you distribute copies of the library or if you modify it.
+
+  For example, if you distribute copies of the library, whether gratis
+or for a fee, you must give the recipients all the rights that we gave
+you.  You must make sure that they, too, receive or can get the source
+code.  If you link other code with the library, you must provide
+complete object files to the recipients, so that they can relink them
+with the library after making changes to the library and recompiling
+it.  And you must show them these terms so they know their rights.
+
+  We protect your rights with a two-step method: (1) we copyright the
+library, and (2) we offer you this license, which gives you legal
+permission to copy, distribute and/or modify the library.
+
+  To protect each distributor, we want to make it very clear that
+there is no warranty for the free library.  Also, if the library is
+modified by someone else and passed on, the recipients should know
+that what they have is not the original version, so that the original
+author's reputation will not be affected by problems that might be
+introduced by others.
+
+  Finally, software patents pose a constant threat to the existence of
+any free program.  We wish to make sure that a company cannot
+effectively restrict the users of a free program by obtaining a
+restrictive license from a patent holder.  Therefore, we insist that
+any patent license obtained for a version of the library must be
+consistent with the full freedom of use specified in this license.
+
+  Most GNU software, including some libraries, is covered by the
+ordinary GNU General Public License.  This license, the GNU Lesser
+General Public License, applies to certain designated libraries, and
+is quite different from the ordinary General Public License.  We use
+this license for certain libraries in order to permit linking those
+libraries into non-free programs.
+
+  When a program is linked with a library, whether statically or using
+a shared library, the combination of the two is legally speaking a
+combined work, a derivative of the original library.  The ordinary
+General Public License therefore permits such linking only if the
+entire combination fits its criteria of freedom.  The Lesser General
+Public License permits more lax criteria for linking other code with
+the library.
+
+  We call this license the "Lesser" General Public License because it
+does Less to protect the user's freedom than the ordinary General
+Public License.  It also provides other free software developers Less
+of an advantage over competing non-free programs.  These disadvantages
+are the reason we use the ordinary General Public License for many
+libraries.  However, the Lesser license provides advantages in certain
+special circumstances.
+
+  For example, on rare occasions, there may be a special need to
+encourage the widest possible use of a certain library, so that it becomes
+a de-facto standard.  To achieve this, non-free programs must be
+allowed to use the library.  A more frequent case is that a free
+library does the same job as widely used non-free libraries.  In this
+case, there is little to gain by limiting the free library to free
+software only, so we use the Lesser General Public License.
+
+  In other cases, permission to use a particular library in non-free
+programs enables a greater number of people to use a large body of
+free software.  For example, permission to use the GNU C Library in
+non-free programs enables many more people to use the whole GNU
+operating system, as well as its variant, the GNU/Linux operating
+system.
+
+  Although the Lesser General Public License is Less protective of the
+users' freedom, it does ensure that the user of a program that is
+linked with the Library has the freedom and the wherewithal to run
+that program using a modified version of the Library.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.  Pay close attention to the difference between a
+"work based on the library" and a "work that uses the library".  The
+former contains code derived from the library, whereas the latter must
+be combined with the library in order to run.
+
+                 GNU LESSER GENERAL PUBLIC LICENSE
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+  0. This License Agreement applies to any software library or other
+program which contains a notice placed by the copyright holder or
+other authorized party saying it may be distributed under the terms of
+this Lesser General Public License (also called "this License").
+Each licensee is addressed as "you".
+
+  A "library" means a collection of software functions and/or data
+prepared so as to be conveniently linked with application programs
+(which use some of those functions and data) to form executables.
+
+  The "Library", below, refers to any such software library or work
+which has been distributed under these terms.  A "work based on the
+Library" means either the Library or any derivative work under
+copyright law: that is to say, a work containing the Library or a
+portion of it, either verbatim or with modifications and/or translated
+straightforwardly into another language.  (Hereinafter, translation is
+included without limitation in the term "modification".)
+
+  "Source code" for a work means the preferred form of the work for
+making modifications to it.  For a library, complete source code means
+all the source code for all modules it contains, plus any associated
+interface definition files, plus the scripts used to control compilation
+and installation of the library.
+
+  Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope.  The act of
+running a program using the Library is not restricted, and output from
+such a program is covered only if its contents constitute a work based
+on the Library (independent of the use of the Library in a tool for
+writing it).  Whether that is true depends on what the Library does
+and what the program that uses the Library does.
+
+  1. You may copy and distribute verbatim copies of the Library's
+complete source code as you receive it, in any medium, provided that
+you conspicuously and appropriately publish on each copy an
+appropriate copyright notice and disclaimer of warranty; keep intact
+all the notices that refer to this License and to the absence of any
+warranty; and distribute a copy of this License along with the
+Library.
+
+  You may charge a fee for the physical act of transferring a copy,
+and you may at your option offer warranty protection in exchange for a
+fee.
+
+  2. You may modify your copy or copies of the Library or any portion
+of it, thus forming a work based on the Library, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+    a) The modified work must itself be a software library.
+
+    b) You must cause the files modified to carry prominent notices
+    stating that you changed the files and the date of any change.
+
+    c) You must cause the whole of the work to be licensed at no
+    charge to all third parties under the terms of this License.
+
+    d) If a facility in the modified Library refers to a function or a
+    table of data to be supplied by an application program that uses
+    the facility, other than as an argument passed when the facility
+    is invoked, then you must make a good faith effort to ensure that,
+    in the event an application does not supply such function or
+    table, the facility still operates, and performs whatever part of
+    its purpose remains meaningful.
+
+    (For example, a function in a library to compute square roots has
+    a purpose that is entirely well-defined independent of the
+    application.  Therefore, Subsection 2d requires that any
+    application-supplied function or table used by this function must
+    be optional: if the application does not supply it, the square
+    root function must still compute square roots.)
+
+These requirements apply to the modified work as a whole.  If
+identifiable sections of that work are not derived from the Library,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works.  But when you
+distribute the same sections as part of a whole which is a work based
+on the Library, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Library.
+
+In addition, mere aggregation of another work not based on the Library
+with the Library (or with a work based on the Library) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+  3. You may opt to apply the terms of the ordinary GNU General Public
+License instead of this License to a given copy of the Library.  To do
+this, you must alter all the notices that refer to this License, so
+that they refer to the ordinary GNU General Public License, version 2,
+instead of to this License.  (If a newer version than version 2 of the
+ordinary GNU General Public License has appeared, then you can specify
+that version instead if you wish.)  Do not make any other change in
+these notices.
+
+  Once this change is made in a given copy, it is irreversible for
+that copy, so the ordinary GNU General Public License applies to all
+subsequent copies and derivative works made from that copy.
+
+  This option is useful when you wish to copy part of the code of
+the Library into a program that is not a library.
+
+  4. You may copy and distribute the Library (or a portion or
+derivative of it, under Section 2) in object code or executable form
+under the terms of Sections 1 and 2 above provided that you accompany
+it with the complete corresponding machine-readable source code, which
+must be distributed under the terms of Sections 1 and 2 above on a
+medium customarily used for software interchange.
+
+  If distribution of object code is made by offering access to copy
+from a designated place, then offering equivalent access to copy the
+source code from the same place satisfies the requirement to
+distribute the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+  5. A program that contains no derivative of any portion of the
+Library, but is designed to work with the Library by being compiled or
+linked with it, is called a "work that uses the Library".  Such a
+work, in isolation, is not a derivative work of the Library, and
+therefore falls outside the scope of this License.
+
+  However, linking a "work that uses the Library" with the Library
+creates an executable that is a derivative of the Library (because it
+contains portions of the Library), rather than a "work that uses the
+library".  The executable is therefore covered by this License.
+Section 6 states terms for distribution of such executables.
+
+  When a "work that uses the Library" uses material from a header file
+that is part of the Library, the object code for the work may be a
+derivative work of the Library even though the source code is not.
+Whether this is true is especially significant if the work can be
+linked without the Library, or if the work is itself a library.  The
+threshold for this to be true is not precisely defined by law.
+
+  If such an object file uses only numerical parameters, data
+structure layouts and accessors, and small macros and small inline
+functions (ten lines or less in length), then the use of the object
+file is unrestricted, regardless of whether it is legally a derivative
+work.  (Executables containing this object code plus portions of the
+Library will still fall under Section 6.)
+
+  Otherwise, if the work is a derivative of the Library, you may
+distribute the object code for the work under the terms of Section 6.
+Any executables containing that work also fall under Section 6,
+whether or not they are linked directly with the Library itself.
+
+  6. As an exception to the Sections above, you may also combine or
+link a "work that uses the Library" with the Library to produce a
+work containing portions of the Library, and distribute that work
+under terms of your choice, provided that the terms permit
+modification of the work for the customer's own use and reverse
+engineering for debugging such modifications.
+
+  You must give prominent notice with each copy of the work that the
+Library is used in it and that the Library and its use are covered by
+this License.  You must supply a copy of this License.  If the work
+during execution displays copyright notices, you must include the
+copyright notice for the Library among them, as well as a reference
+directing the user to the copy of this License.  Also, you must do one
+of these things:
+
+    a) Accompany the work with the complete corresponding
+    machine-readable source code for the Library including whatever
+    changes were used in the work (which must be distributed under
+    Sections 1 and 2 above); and, if the work is an executable linked
+    with the Library, with the complete machine-readable "work that
+    uses the Library", as object code and/or source code, so that the
+    user can modify the Library and then relink to produce a modified
+    executable containing the modified Library.  (It is understood
+    that the user who changes the contents of definitions files in the
+    Library will not necessarily be able to recompile the application
+    to use the modified definitions.)
+
+    b) Use a suitable shared library mechanism for linking with the
+    Library.  A suitable mechanism is one that (1) uses at run time a
+    copy of the library already present on the user's computer system,
+    rather than copying library functions into the executable, and (2)
+    will operate properly with a modified version of the library, if
+    the user installs one, as long as the modified version is
+    interface-compatible with the version that the work was made with.
+
+    c) Accompany the work with a written offer, valid for at
+    least three years, to give the same user the materials
+    specified in Subsection 6a, above, for a charge no more
+    than the cost of performing this distribution.
+
+    d) If distribution of the work is made by offering access to copy
+    from a designated place, offer equivalent access to copy the above
+    specified materials from the same place.
+
+    e) Verify that the user has already received a copy of these
+    materials or that you have already sent this user a copy.
+
+  For an executable, the required form of the "work that uses the
+Library" must include any data and utility programs needed for
+reproducing the executable from it.  However, as a special exception,
+the materials to be distributed need not include anything that is
+normally distributed (in either source or binary form) with the major
+components (compiler, kernel, and so on) of the operating system on
+which the executable runs, unless that component itself accompanies
+the executable.
+
+  It may happen that this requirement contradicts the license
+restrictions of other proprietary libraries that do not normally
+accompany the operating system.  Such a contradiction means you cannot
+use both them and the Library together in an executable that you
+distribute.
+
+  7. You may place library facilities that are a work based on the
+Library side-by-side in a single library together with other library
+facilities not covered by this License, and distribute such a combined
+library, provided that the separate distribution of the work based on
+the Library and of the other library facilities is otherwise
+permitted, and provided that you do these two things:
+
+    a) Accompany the combined library with a copy of the same work
+    based on the Library, uncombined with any other library
+    facilities.  This must be distributed under the terms of the
+    Sections above.
+
+    b) Give prominent notice with the combined library of the fact
+    that part of it is a work based on the Library, and explaining
+    where to find the accompanying uncombined form of the same work.
+
+  8. You may not copy, modify, sublicense, link with, or distribute
+the Library except as expressly provided under this License.  Any
+attempt otherwise to copy, modify, sublicense, link with, or
+distribute the Library is void, and will automatically terminate your
+rights under this License.  However, parties who have received copies,
+or rights, from you under this License will not have their licenses
+terminated so long as such parties remain in full compliance.
+
+  9. You are not required to accept this License, since you have not
+signed it.  However, nothing else grants you permission to modify or
+distribute the Library or its derivative works.  These actions are
+prohibited by law if you do not accept this License.  Therefore, by
+modifying or distributing the Library (or any work based on the
+Library), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.
+
+  10. Each time you redistribute the Library (or any work based on the
+Library), the recipient automatically receives a license from the
+original licensor to copy, distribute, link with or modify the Library
+subject to these terms and conditions.  You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties with
+this License.
+
+  11. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Library at all.  For example, if a patent
+license would not permit royalty-free redistribution of the Library by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Library.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system which is
+implemented by public license practices.  Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+  12. If the distribution and/or use of the Library is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Library under this License may add
+an explicit geographical distribution limitation excluding those countries,
+so that distribution is permitted only in or among countries not thus
+excluded.  In such case, this License incorporates the limitation as if
+written in the body of this License.
+
+  13. The Free Software Foundation may publish revised and/or new
+versions of the Lesser General Public License from time to time.
+Such new versions will be similar in spirit to the present version,
+but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number.  If the Library
+specifies a version number of this License which applies to it and
+"any later version", you have the option of following the terms and
+conditions either of that version or of any later version published by
+the Free Software Foundation.  If the Library does not specify a
+license version number, you may choose any version ever published by
+the Free Software Foundation.
+
+  14. If you wish to incorporate parts of the Library into other free
+programs whose distribution conditions are incompatible with these,
+write to the author to ask for permission.  For software which is
+copyrighted by the Free Software Foundation, write to the Free
+Software Foundation; we sometimes make exceptions for this.  Our
+decision will be guided by the two goals of preserving the free status
+of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.
+
+                           NO WARRANTY
+
+  15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
+WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
+EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
+OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
+KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
+LIBRARY IS WITH YOU.  SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
+WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
+AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
+FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
+CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
+LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
+RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
+FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
+SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
+DAMAGES.
+
+                    END OF TERMS AND CONDITIONS
+
diff --git a/subprojects/vala-unit/README.md b/subprojects/vala-unit/README.md
new file mode 100644
index 000000000..aeb012d10
--- /dev/null
+++ b/subprojects/vala-unit/README.md
@@ -0,0 +1,6 @@
+
+ValaUnit
+========
+
+A Glib-based, xUnit-style unit testing and mock object framework for
+Vala projects.
diff --git a/subprojects/vala-unit/meson.build b/subprojects/vala-unit/meson.build
new file mode 100644
index 000000000..eb1975b3e
--- /dev/null
+++ b/subprojects/vala-unit/meson.build
@@ -0,0 +1,152 @@
+project(
+  'vala-unit',
+  [ 'vala', 'c' ],
+  version: '1.0',
+  license: 'LGPL2.1+',
+  meson_version: '>= 0.50',
+)
+
+enable_install = get_option('install')
+enable_valadoc = get_option('valadoc')
+
+add_project_arguments(
+  [
+    '--abi-stability',
+    '--enable-checking',
+    '--enable-experimental-non-null',
+    '--fatal-warnings',
+    '--nostdpkg'
+  ],
+  language: 'vala'
+)
+
+target_vala = '0.44'
+target_glib = '2.62'
+
+if not meson.get_compiler('vala').version().version_compare('>=' + target_vala)
+  error('Vala does not meet minimum required version: ' + target_vala)
+endif
+
+gee = dependency('gee-0.8')
+gio = dependency('gio-2.0')
+glib = dependency('glib-2.0', version: '>=' + target_glib)
+gobject = dependency('gobject-2.0')
+
+g_ir_compiler = find_program('g-ir-compiler')
+if enable_valadoc
+  valadoc = find_program('valadoc')
+endif
+
+dependencies = [
+  gee,
+  gio,
+  glib,
+  gobject
+]
+
+lib_sources = files(
+  'src/async-result-waiter.vala',
+  'src/collection-assertions.vala',
+  'src/expected-call.vala',
+  'src/mock-object.vala',
+  'src/test-adaptor.vala',
+  'src/test-assertions.vala',
+  'src/test-case.vala',
+)
+
+test_sources = files(
+  'test/collection-assertions.vala',
+  'test/test-assertions.vala',
+  'test/test-driver.vala',
+)
+
+package_name = 'ValaUnit'
+package_version = '1.0'
+package_full = '@0@-@1@'.format(package_name, package_version)
+package_vapi = '@0@-@1@'.format(meson.project_name(), package_version)
+package_gir = package_full + '.gir'
+
+vala_unit_lib = library(
+  meson.project_name(),
+  lib_sources,
+  dependencies: dependencies,
+  # Ensure we always get debug symbols.
+  override_options : [
+    'debug=true',
+    'strip=false',
+  ],
+  vala_vapi: package_vapi + '.vapi',
+  vala_gir: package_gir,
+  install: enable_install,
+  install_dir: [true, true, true, true]
+)
+
+vala_unit_dep = declare_dependency(
+  link_with : vala_unit_lib,
+  include_directories: include_directories('.')
+)
+
+custom_target(
+  meson.project_name() + '-typelib',
+  command: [
+    g_ir_compiler,
+    '--output', '@OUTPUT@',
+    meson.current_build_dir() / package_gir,
+  ],
+  output: [package_full + '.typelib'],
+  depends: vala_unit_lib,
+  install: enable_install,
+  install_dir: get_option('libdir') / 'girepository-1.0'
+)
+
+if enable_valadoc
+  # Hopefully Meson will get baked-in valadoc support, so we don't have
+  # to do this any more. https://github.com/mesonbuild/meson/issues/894
+  valadoc_dep_args = []
+  foreach dep : dependencies
+    valadoc_dep_args += '--pkg'
+    valadoc_dep_args += dep.name()
+  endforeach
+
+  docs = custom_target(
+    'valadoc',
+    build_by_default: true,
+    depends: [vala_unit_lib],
+    input: lib_sources,
+    output: 'valadoc',
+    command: [
+      valadoc,
+      '--verbose',
+      '--force',
+      '--fatal-warnings',
+      '--package-name=@0@'.format(package_vapi),
+      '--package-version=@0@'.format(meson.project_version()),
+      '-b', meson.current_source_dir(),
+      '-o', '@OUTPUT@',
+      '@INPUT@',
+    ] + valadoc_dep_args
+  )
+
+  if enable_install
+    install_subdir(
+      meson.current_build_dir() / 'valadoc',
+      install_dir: get_option('datadir') / 'doc' / 'vala-unit' / 'valadoc'
+    )
+  endif
+endif
+
+test_driver = executable(
+  'test-driver',
+  test_sources,
+  dependencies: dependencies + [ vala_unit_dep ],
+  # Always do a plain debug build to avoid compiler optimsations that
+  # might render testing invalid, and to ensure we get debug symbols.
+  # Ensure we always get debug symbols.
+  override_options : [
+    'debug=true',
+    'optimization=0',
+    'strip=false',
+  ],
+)
+
+test('tests', test_driver)
diff --git a/subprojects/vala-unit/meson_options.txt b/subprojects/vala-unit/meson_options.txt
new file mode 100644
index 000000000..e4a8a24da
--- /dev/null
+++ b/subprojects/vala-unit/meson_options.txt
@@ -0,0 +1,12 @@
+option(
+  'install',
+  type: 'boolean',
+  value: true,
+  description: 'Whether to install the library\'s files'
+)
+option(
+  'valadoc',
+  type: 'boolean',
+  value: true,
+  description: 'Whether to build the documentaton (requires valadoc).'
+)
diff --git a/subprojects/vala-unit/src/async-result-waiter.vala 
b/subprojects/vala-unit/src/async-result-waiter.vala
new file mode 100644
index 000000000..02025d970
--- /dev/null
+++ b/subprojects/vala-unit/src/async-result-waiter.vala
@@ -0,0 +1,108 @@
+/*
+ * Copyright © 2020 Michael Gratton <mike vee net>
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+
+/**
+ * Allows non-async code to wait for async calls to be completed.
+ *
+ * To use instances of this class, call an async function or method
+ * using the `begin()` form, passing {@link async_completion} as
+ * completion argument (that is, the last argument):
+ *
+ * {{{
+ *     var waiter = new AsyncResultWaiter();
+ *     my_async_call.begin("foo", waiter.async_completion);
+ * }}}
+ *
+ * Then, when you want to ensure the call is complete, pass the result
+ * of calling {@link async_result} to its `end()` form:
+ *
+ * {{{
+ *     my_async_call.end(waiter.async_result());
+ * }}}
+ *
+ * This will block until the async call has completed.
+ *
+ * Note that {@link TestCase} exposes the same interface, so it is
+ * usually easier to just call those when testing a single async call,
+ * or multiple, non-interleaved async calls.
+ *
+ * This class is implemented as a FIFO queue of {@link
+ * GLib.AsyncResult} instances, and thus can be used for waiting for
+ * multiple calls. Note however the ordering depends on the order in
+ * which the async calls being invoked are executed and are
+ * completed. Thus if testing multiple interleaved async calls, you
+ * should probably use an instance of this class per call.
+ */
+public class ValaUnit.AsyncResultWaiter : GLib.Object {
+
+
+    /** The main loop that is executed when waiting for async results. */
+    public GLib.MainContext main_loop { get; construct set; }
+
+    private GLib.AsyncQueue<GLib.AsyncResult> results =
+        new GLib.AsyncQueue<GLib.AsyncResult>();
+
+
+    /**
+     * Constructs a new waiter.
+     *
+     * @param main_loop a main loop context to execute when waiting
+     * for an async result
+     */
+    public AsyncResultWaiter(GLib.MainContext main_loop) {
+        Object(main_loop: main_loop);
+    }
+
+    /**
+     * The last argument of an async call to be tested.
+     *
+     * Records the given {@link GLib.AsyncResult}, adding it to the
+     * internal FIFO queue. This method should be called as the
+     * completion of an async call to be tested.
+     *
+     * To use it, pass as the last argument to the `begin()` form of
+     * the async call:
+     *
+     * {{{
+     *     var waiter = new AsyncResultWaiter();
+     *     my_async_call.begin("foo", waiter.async_completion);
+     * }}}
+     */
+    public void async_completion(GLib.Object? object,
+                                 GLib.AsyncResult result) {
+        this.results.push(result);
+        // Notify the loop so that if async_result() has already been
+        // called, that method won't block.
+        this.main_loop.wakeup();
+    }
+
+    /**
+     * Waits for async calls to complete, returning the most recent one.
+     *
+     * This returns the first {@link GLib.AsyncResult} from the
+     * internal FIFO queue that has been provided by {@link
+     * async_completion}. If none are available, it will pump the main
+     * loop, blocking until one becomes available.
+     *
+     * To use it, pass its return value as the argument to the `end()`
+     * call:
+     *
+     * {{{
+     *     my_async_call.end(waiter.async_result());
+     * }}}
+     */
+    public GLib.AsyncResult async_result() {
+        GLib.AsyncResult? result = this.results.try_pop();
+        while (result == null) {
+            this.main_loop.iteration(true);
+            result = this.results.try_pop();
+        }
+        return (GLib.AsyncResult) result;
+    }
+
+}
diff --git a/subprojects/vala-unit/src/collection-assertions.vala 
b/subprojects/vala-unit/src/collection-assertions.vala
new file mode 100644
index 000000000..3b34acda1
--- /dev/null
+++ b/subprojects/vala-unit/src/collection-assertions.vala
@@ -0,0 +1,499 @@
+/*
+ * Copyright © 2020 Michael Gratton <mike vee net>
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+/**
+ * Defines default test assertions for specific strings, arrays and collections.
+ *
+ * Call {@link TestAssertions.assert_string}, {@link
+ * TestAssertions.assert_array} and {@link
+ * TestAssertions.assert_collection} methods, accessible from
+ * subclasses of {@link TestCase} to construct these objects.
+ */
+public interface ValaUnit.CollectionAssertions<E> : GLib.Object {
+
+
+    /**
+     * Asserts the collection is empty.
+     *
+     * Returns the same object to allow assertion chaining.
+     */
+    public abstract CollectionAssertions<E> is_empty()
+        throws GLib.Error;
+
+    /**
+     * Asserts the collection is non-empty.
+     *
+     * Returns the same object to allow assertion chaining.
+     */
+    public abstract CollectionAssertions<E> is_non_empty()
+        throws GLib.Error;
+
+    /**
+     * Asserts the collection has an expected length.
+     *
+     * Returns the same object to allow assertion chaining.
+     */
+    public abstract CollectionAssertions<E> size(uint32 expected)
+        throws GLib.Error;
+
+    /**
+     * Asserts the collection contains an expected element.
+     *
+     * Returns the same object to allow assertion chaining.
+     */
+    public abstract CollectionAssertions<E> contains(E expected)
+        throws GLib.Error;
+
+    /**
+     * Asserts the collection does not contain an expected element.
+     *
+     * Returns the same object to allow assertion chaining.
+     */
+    public abstract CollectionAssertions<E> not_contains(E expected)
+        throws GLib.Error;
+
+    /**
+     * Asserts the collection's first element is as expected.
+     *
+     * Returns the same object to allow assertion chaining.
+     */
+    public CollectionAssertions<E> first_is(E expected)
+        throws GLib.Error {
+        at_index_is(0, expected);
+        return this;
+    }
+
+    /**
+     * Asserts the collection's nth element is as expected.
+     *
+     * Note the give position is is 1-based, not 0-based.
+     *
+     * Returns the same object to allow assertion chaining.
+     */
+    public abstract CollectionAssertions<E> at_index_is(uint32 position,
+                                                        E expected)
+        throws GLib.Error;
+
+
+}
+
+internal class ValaUnit.StringCollectionAssertion : GLib.Object,
+    CollectionAssertions<string> {
+
+
+    private string actual;
+    private string? context;
+
+
+    internal StringCollectionAssertion(string actual, string? context) {
+        this.actual = actual;
+        this.context = context;
+    }
+
+    public CollectionAssertions<string> is_empty() throws GLib.Error {
+        if (this.actual.length != 0) {
+            ValaUnit.assert(
+                "“%s”.length = %u, expected empty".printf(
+                    this.actual,
+                    this.actual.length
+                ),
+                this.context
+            );
+        }
+        return this;
+    }
+
+    public CollectionAssertions<string> is_non_empty()
+        throws GLib.Error {
+        if (this.actual.length == 0) {
+            ValaUnit.assert(
+                "string is empty, expected non-empty", this.context
+            );
+        }
+        return this;
+    }
+
+    public CollectionAssertions<string> size(uint32 expected)
+        throws GLib.Error {
+        if (this.actual.length != expected) {
+            ValaUnit.assert(
+                "“%s”.length = %u, expected %u".printf(
+                    this.actual,
+                    this.actual.length,
+                    expected
+                ),
+                this.context
+            );
+        }
+        return this;
+    }
+
+    public CollectionAssertions<string> contains(string expected)
+        throws GLib.Error {
+        if (!(expected in this.actual)) {
+            ValaUnit.assert(
+                "“%s” does not contain “%s”".printf(
+                    this.actual,
+                    expected
+                ),
+                this.context
+            );
+        }
+        return this;
+    }
+
+    public CollectionAssertions<string> not_contains(string expected)
+        throws GLib.Error {
+        if (expected in this.actual) {
+            ValaUnit.assert(
+                "“%s” should not contain “%s”".printf(
+                    this.actual,
+                    expected
+                ),
+                this.context
+            );
+        }
+        return this;
+    }
+
+    public CollectionAssertions<string> at_index_is(uint32 index,
+                                                    string expected)
+        throws GLib.Error {
+        if (this.actual.index_of(expected) != index) {
+            ValaUnit.assert(
+                "“%s”[%u:%u] != “%s”".printf(
+                    this.actual,
+                    index,
+                    index + (uint) expected.length,
+                    expected
+                ),
+                this.context
+            );
+        }
+        return this;
+    }
+
+}
+
+
+internal class ValaUnit.ArrayCollectionAssertion<E> : GLib.Object,
+    CollectionAssertions<E> {
+
+
+    private E[] actual;
+    private string? context;
+
+
+    internal ArrayCollectionAssertion(E[] actual, string? context)
+        throws TestError {
+        this.actual = actual;
+        this.context = context;
+
+        GLib.Type UNSUPPORTED[] = {
+            typeof(bool),
+            typeof(char),
+            typeof(short),
+            typeof(int),
+            typeof(int64),
+            typeof(uchar),
+            typeof(ushort),
+            typeof(uint),
+            typeof(uint64),
+            typeof(float),
+            typeof(double)
+        };
+        var type = typeof(E);
+        if (type.is_enum() || type in UNSUPPORTED) {
+            throw new TestError.UNSUPPORTED(
+                "Arrays containing non-pointer values not currently supported. See GNOME/vala#964"
+            );
+        }
+    }
+
+    public CollectionAssertions<E> is_empty() throws GLib.Error {
+        if (this.actual.length != 0) {
+            ValaUnit.assert(
+                "%s is not empty".printf(
+                    to_collection_display()
+                ),
+                this.context
+            );
+        }
+        return this;
+    }
+
+    public CollectionAssertions<E> is_non_empty()
+        throws GLib.Error {
+        if (this.actual.length == 0) {
+            ValaUnit.assert(
+                "%s is empty, expected non-empty".printf(
+                    to_collection_display()
+                ),
+                this.context
+            );
+        }
+        return this;
+    }
+
+    public CollectionAssertions<E> size(uint32 expected)
+        throws GLib.Error {
+        if (this.actual.length != expected) {
+            ValaUnit.assert(
+                "%s.length == %d, expected %u".printf(
+                    to_collection_display(),
+                    this.actual.length,
+                    expected
+                ),
+                this.context
+            );
+        }
+        return this;
+    }
+
+    public CollectionAssertions<E> contains(E expected)
+        throws GLib.Error {
+        E boxed_expected = box_value(expected);
+        bool found = false;
+        for (int i = 0; i < this.actual.length; i++) {
+            try {
+                assert_equal(box_value(this.actual[i]), boxed_expected);
+                found = true;
+                break;
+            } catch (TestError.FAILED err) {
+                // no-op
+            }
+        }
+        if (!found) {
+            ValaUnit.assert(
+                "%s does not contain %s".printf(
+                    to_collection_display(),
+                    to_display_string(boxed_expected)
+                ),
+                this.context
+            );
+        }
+        return this;
+    }
+
+    public CollectionAssertions<E> not_contains(E expected)
+        throws GLib.Error {
+        E boxed_expected = box_value(expected);
+        for (int i = 0; i < this.actual.length; i++) {
+            try {
+                assert_equal(box_value(this.actual[i]), boxed_expected);
+                ValaUnit.assert(
+                    "%s does not contain %s".printf(
+                        to_collection_display(),
+                        to_display_string(boxed_expected)
+                    ),
+                    this.context
+                );
+                break;
+            } catch (TestError.FAILED err) {
+                // no-op
+            }
+        }
+        return this;
+    }
+
+    public CollectionAssertions<E> at_index_is(uint32 index, E expected)
+        throws GLib.Error {
+        if (index >= this.actual.length) {
+            ValaUnit.assert(
+                "%s.length == %u, expected >= %u".printf(
+                    to_collection_display(),
+                    this.actual.length,
+                    index
+                ),
+                this.context
+            );
+        }
+        E boxed_actual = box_value(this.actual[index]);
+        E boxed_expected = box_value(expected);
+        try {
+            assert_equal(boxed_actual, boxed_expected);
+        } catch (TestError.FAILED err) {
+            ValaUnit.assert(
+                "%s[%u] == %s, expected: %s".printf(
+                    to_collection_display(),
+                    index,
+                    to_display_string(boxed_actual),
+                    to_display_string(boxed_expected)
+                ),
+                this.context
+            );
+        }
+        return this;
+    }
+
+    private string to_collection_display() {
+        var buf = new GLib.StringBuilder();
+        int len = this.actual.length;
+        buf.append(typeof(E).name());
+        buf.append("[]");
+
+        if (len > 0) {
+            buf.append_c('{');
+            buf.append(to_display_string(box_value(this.actual[0])));
+
+            if (len == 2) {
+                buf.append_c(',');
+                buf.append(to_display_string(box_value(this.actual[1])));
+            } else if (len > 2) {
+                buf.append(", … (%d more)".printf(len - 2));
+            }
+            buf.append_c('}');
+        }
+        return buf.str;
+    }
+
+}
+
+
+internal class ValaUnit.GeeCollectionAssertion<E> :
+    GLib.Object,
+    CollectionAssertions<E> {
+
+
+    private Gee.Collection<E> actual;
+    private string? context;
+
+
+    internal GeeCollectionAssertion(Gee.Collection<E> actual, string? context) {
+        this.actual = actual;
+        this.context = context;
+    }
+
+    public CollectionAssertions<E> is_empty() throws GLib.Error {
+        if (!this.actual.is_empty) {
+            ValaUnit.assert(
+                "%s.length = %d, expected empty".printf(
+                    to_collection_display(),
+                    this.actual.size
+                ),
+                this.context
+            );
+        }
+        return this;
+    }
+
+    public CollectionAssertions<E> is_non_empty()
+        throws GLib.Error {
+        if (this.actual.is_empty) {
+            ValaUnit.assert(
+                "%s is empty, expected non-empty".printf(
+                    to_collection_display()
+                ),
+                this.context
+            );
+        }
+        return this;
+    }
+
+    public CollectionAssertions<E> size(uint32 expected)
+        throws GLib.Error {
+        if (this.actual.size != expected) {
+            ValaUnit.assert(
+                "%s.size == %d, expected %u".printf(
+                    to_collection_display(),
+                    this.actual.size,
+                    expected
+                ),
+                this.context
+            );
+        }
+        return this;
+    }
+
+    public CollectionAssertions<E> contains(E expected)
+        throws GLib.Error {
+        if (!(expected in this.actual)) {
+            ValaUnit.assert(
+                "%s does not contain %s".printf(
+                    to_collection_display(),
+                    to_display_string(box_value(expected))
+                ),
+                this.context
+            );
+        }
+        return this;
+    }
+
+    public CollectionAssertions<E> not_contains(E expected)
+        throws GLib.Error {
+        if (expected in this.actual) {
+            ValaUnit.assert(
+                "%s should not contain %s".printf(
+                    to_collection_display(),
+                    to_display_string(box_value(expected))
+                ),
+                this.context
+            );
+        }
+        return this;
+    }
+
+    public CollectionAssertions<E> at_index_is(uint32 index, E expected)
+        throws GLib.Error {
+        if (index >= this.actual.size) {
+            ValaUnit.assert(
+                "%s.length == %d, expected >= %u".printf(
+                    to_collection_display(),
+                    this.actual.size,
+                    index
+                ),
+                this.context
+            );
+        }
+        Gee.Iterator<E> iterator = this.actual.iterator();
+        for (int i = 0; i <= index; i++) {
+            iterator.next();
+        }
+        E boxed_actual = box_value(iterator.get());
+        E boxed_expected = box_value(expected);
+        try {
+            assert_equal(boxed_actual, boxed_expected);
+        } catch (TestError.FAILED err) {
+            ValaUnit.assert(
+                "%s[%u] == %s, expected: %s".printf(
+                    to_collection_display(),
+                    index,
+                    to_display_string(boxed_actual),
+                    to_display_string(boxed_expected)
+                ),
+                this.context
+            );
+        }
+        return this;
+    }
+
+    private string to_collection_display() {
+        var buf = new GLib.StringBuilder();
+        int len = this.actual.size;
+        buf.append("Gee.Collection<");
+        buf.append(typeof(E).name());
+        buf.append_c('>');
+
+        if (len > 0) {
+            Gee.Iterator<E> iterator = this.actual.iterator();
+            iterator.next();
+            buf.append_c('{');
+            buf.append(to_display_string(box_value(iterator.get())));
+
+            if (len == 2) {
+                iterator.next();
+                buf.append_c(',');
+                buf.append(to_display_string(box_value(iterator.get())));
+            } else if (len > 2) {
+                buf.append(", … (%d more)".printf(len - 2));
+            }
+            buf.append_c('}');
+        }
+        return buf.str;
+    }
+
+}
diff --git a/subprojects/vala-unit/src/expected-call.vala b/subprojects/vala-unit/src/expected-call.vala
new file mode 100644
index 000000000..1230624c9
--- /dev/null
+++ b/subprojects/vala-unit/src/expected-call.vala
@@ -0,0 +1,148 @@
+/*
+ * Copyright 2018-2020 Michael Gratton <mike vee net>
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+/**
+ * Represents an expected method call on a mock object.
+ *
+ * An instance of this object is returned when calling {@link
+ * MockObject.expect_call}, and may be used to further specify
+ * expectations, such that the mock method should throw a specific
+ * error or return a specific value or object.
+ */
+public class ValaUnit.ExpectedCall : GLib.Object {
+
+
+    /** Options for handling async calls. */
+    public enum AsyncCallOptions {
+
+        /** Check and return from the expected call immediately. */
+        CONTINUE,
+
+        /**
+         * Check and return from the expected call when idle.
+         *
+         * This will yield when the call is made, being resuming when
+         * idle.
+         */
+        CONTINUE_AT_IDLE,
+
+        /**
+         * Check and return from the expected call when requested.
+         *
+         * This will yield when the call is made, resuming when {@link
+         * ExpectedCall.async_resume} is called.
+         */
+        PAUSE;
+
+    }
+
+
+    /** The name of the expected call. */
+    public string name { get; private set; }
+
+    /** Determines how async calls are handled. */
+    public AsyncCallOptions async_behaviour {
+        get; private set; default = CONTINUE;
+    }
+
+    /** The error to be thrown by the call, if any. */
+    public GLib.Error? throw_error { get; private set; default = null; }
+
+    /** An object to be returned by the call, if any. */
+    public GLib.Object? return_object { get; private set; default = null; }
+
+    /** A value to be returned by the call, if any. */
+    public GLib.Variant? return_value { get; private set; default = null; }
+
+    /** Determines if the call has been made or not. */
+    public bool was_called { get; private set; default = false; }
+
+    /** Determines if an async call has been resumed or not. */
+    public bool async_resumed { get; private set; default = false; }
+
+    // XXX Arrays can't be GObject properties :(
+    internal GLib.Object?[]? expected_args = null;
+    private GLib.Object?[]? called_args = null;
+
+    internal unowned GLib.SourceFunc? async_callback = null;
+
+
+    internal ExpectedCall(string name, GLib.Object?[]? args) {
+        this.name = name;
+        this.expected_args = args;
+    }
+
+    /** Sets the behaviour for an async call. */
+    public ExpectedCall async_call(AsyncCallOptions behaviour) {
+        this.async_behaviour = behaviour;
+        return this;
+    }
+
+    /** Sets an object that the call should return. */
+    public ExpectedCall returns_object(GLib.Object value) {
+        this.return_object = value;
+        return this;
+    }
+
+    /** Sets a bool value that the call should return. */
+    public ExpectedCall returns_boolean(bool value) {
+        this.return_value = new GLib.Variant.boolean(value);
+        return this;
+    }
+
+    /** Sets an error that the cal should throw. */
+    public ExpectedCall @throws(GLib.Error err) {
+        this.throw_error = err;
+        return this;
+    }
+
+    /**
+     * Resumes an async call that has been paused.
+     *
+     * Throws an assertion error if the call has not yet been called
+     * or has not been paused.
+     */
+    public void async_resume() throws TestError {
+        if (this.async_callback == null) {
+            throw new TestError.FAILED(
+                "Async call not called, could not resume"
+            );
+        }
+        if (this.async_resumed) {
+            throw new TestError.FAILED(
+                "Async call already resumed"
+            );
+        }
+        this.async_resumed = true;
+        this.async_callback();
+    }
+
+    /** Determines if an argument was given in the specific position. */
+    public T called_arg<T>(int pos) throws TestError {
+        if (this.called_args == null || this.called_args.length < (pos + 1)) {
+            throw new TestError.FAILED(
+                "%s call argument %u, type %s, not present".printf(
+                    this.name, pos, typeof(T).name()
+                )
+            );
+        }
+        if (!(this.called_args[pos] is T)) {
+            throw new TestError.FAILED(
+                "%s call argument %u not of type %s".printf(
+                    this.name, pos, typeof(T).name()
+                )
+            );
+        }
+        return (T) this.called_args[pos];
+    }
+
+    internal void called(GLib.Object?[]? args) {
+        this.was_called = true;
+        this.called_args = args;
+    }
+
+}
diff --git a/subprojects/vala-unit/src/mock-object.vala b/subprojects/vala-unit/src/mock-object.vala
new file mode 100644
index 000000000..766777a43
--- /dev/null
+++ b/subprojects/vala-unit/src/mock-object.vala
@@ -0,0 +1,318 @@
+/*
+ * Copyright © 2018-2020 Michael Gratton <mike vee net>
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+/**
+ * Denotes a dummy object that can be injected into code being tested.
+ *
+ * Mock objects are unit testing fixtures that are used to provide
+ * instances of specific classes or interfaces which are required by
+ * the code being tested. For example, if an object being tested
+ * requires certain objects to be passed in via its constructor or as
+ * arguments of method calls and uses these to implement its
+ * behaviour, mock objects that fulfill these requirements can be used.
+ *
+ * Mock objects provide a means of both ensuring code being tested
+ * makes expected method calls with expected arguments on its
+ * dependencies, and a means of orchestrating the return value and
+ * exceptions raised when these methods are called, if any.
+ *
+ * To specify a specific method should be called on a mock object,
+ * call {@link expect_call} with the name of the method and optionally
+ * the arguments that are expected. The returned {@link ExpectedCall}
+ * object can be used to specify any exception or return values for
+ * the method. After executing the code being tested, call {@link
+ * assert_expectations} to ensure that the actual calls made matched
+ * those expected.
+ */
+public interface ValaUnit.MockObject : GLib.Object, TestAssertions {
+
+
+    public static GLib.Object box_arg<T>(T value) {
+        return new BoxArgument<T>(value);
+    }
+
+    public static GLib.Object int_arg(int value) {
+        return new IntArgument(value);
+    }
+
+    public static GLib.Object uint_arg(uint value) {
+        return new UintArgument(value);
+    }
+
+    protected abstract Gee.Queue<ExpectedCall> expected { get; set; }
+
+
+    public ExpectedCall expect_call(string name, GLib.Object?[]? args = null) {
+        ExpectedCall expected = new ExpectedCall(name, args);
+        this.expected.offer(expected);
+        return expected;
+    }
+
+    public void assert_expectations() throws GLib.Error {
+        assert_true(this.expected.is_empty,
+                    "%d expected calls not made".printf(this.expected.size));
+        reset_expectations();
+    }
+
+    public void reset_expectations() {
+        this.expected.clear();
+    }
+
+    protected bool boolean_call(string name,
+                                GLib.Object?[] args,
+                                bool default_return)
+        throws GLib.Error {
+        ExpectedCall expected = call_made(name, args);
+        return check_boolean_call(expected, default_return);
+    }
+
+    protected async bool boolean_call_async(string name,
+                                            GLib.Object?[] args,
+                                            bool default_return)
+        throws GLib.Error {
+        ExpectedCall expected = call_made(name, args);
+        if (async_call_yield(expected, this.boolean_call_async.callback)) {
+            yield;
+        }
+        return check_boolean_call(expected, default_return);
+    }
+
+    protected R object_call<R>(string name,
+                               GLib.Object?[] args,
+                               R default_return)
+        throws GLib.Error {
+        ExpectedCall expected = call_made(name, args);
+        return check_object_call(expected, default_return);
+    }
+
+    protected async R object_call_async<R>(string name,
+                                           GLib.Object?[] args,
+                                           R default_return)
+        throws GLib.Error {
+        ExpectedCall expected = call_made(name, args);
+        if (async_call_yield(expected, this.object_call_async.callback)) {
+            yield;
+        }
+        return check_object_call(expected, default_return);
+    }
+
+    protected R object_or_throw_call<R>(string name,
+                                        GLib.Object?[] args,
+                                        GLib.Error default_error)
+        throws GLib.Error {
+        ExpectedCall expected = call_made(name, args);
+        return check_object_or_throw_call(expected, default_error);
+    }
+
+    protected async R object_or_throw_call_async<R>(string name,
+                                                    GLib.Object?[] args,
+                                                    GLib.Error default_error)
+        throws GLib.Error {
+        ExpectedCall expected = call_made(name, args);
+        if (async_call_yield(expected, this.object_or_throw_call_async.callback)) {
+            yield;
+        }
+        return check_object_or_throw_call(expected, default_error);
+    }
+
+    protected void void_call(string name, GLib.Object?[] args)
+        throws GLib.Error {
+        ExpectedCall expected = call_made(name, args);
+        check_for_exception(expected);
+    }
+
+    protected async void void_call_async(string name, GLib.Object?[] args)
+        throws GLib.Error {
+        ExpectedCall expected = call_made(name, args);
+        if (async_call_yield(expected, this.void_call_async.callback)) {
+            yield;
+        }
+        check_for_exception(expected);
+    }
+
+    private ExpectedCall call_made(string name, GLib.Object?[] args)
+        throws GLib.Error {
+        assert_false(this.expected.is_empty, "Unexpected call: %s".printf(name));
+
+        ExpectedCall expected = this.expected.poll();
+        assert_equal(name, expected.name, "Unexpected call");
+        if (expected.expected_args != null) {
+            assert_args(args, expected.expected_args, "Call %s".printf(name));
+        }
+
+        expected.called(args);
+        return expected;
+    }
+
+    private void assert_args(GLib.Object?[] actual_args,
+                             GLib.Object?[] expected_args,
+                             string context)
+        throws GLib.Error {
+        int args = 0;
+        foreach (var expected in expected_args) {
+            if (args >= actual_args.length) {
+                break;
+            }
+
+            GLib.Object? actual = actual_args[args];
+            string arg_context = "%s, argument #%d".printf(context, args++);
+
+            if (expected is Argument) {
+                assert_non_null(actual, arg_context);
+                ((Argument) expected).assert((GLib.Object) actual, arg_context);
+            } else if (expected != null) {
+                var non_null_expected = (GLib.Object) expected;
+
+                assert_non_null(actual, arg_context);
+                var non_null_actual = (GLib.Object) actual;
+
+                assert_equal(
+                    non_null_expected.get_type(), non_null_actual.get_type(),
+                    arg_context
+                );
+                assert_equal(
+                    non_null_actual,
+                    non_null_expected,
+                    arg_context
+                );
+            } else {
+                assert_null(actual, arg_context);
+
+            }
+        }
+
+        assert_equal(
+            actual_args.length,
+            expected_args.length,
+            "%s: argument list length".printf(context)
+        );
+    }
+
+    private bool async_call_yield(ExpectedCall expected,
+                                  GLib.SourceFunc @callback) {
+        var @yield = false;
+        if (expected.async_behaviour != CONTINUE) {
+            expected.async_callback = @callback;
+            if (expected.async_behaviour == CONTINUE_AT_IDLE) {
+                GLib.Idle.add(() => {
+                        try {
+                            expected.async_resume();
+                        } catch (GLib.Error err) {
+                            critical(
+                                "Async call already resumed: %s", err.message
+                            );
+                        }
+                        return GLib.Source.REMOVE;
+                    });
+            }
+            @yield = true;
+        }
+        return @yield;
+    }
+
+    private inline bool check_boolean_call(ExpectedCall expected,
+                                           bool default_return)
+        throws GLib.Error {
+        check_for_exception(expected);
+        bool return_value = default_return;
+        if (expected.return_value != null) {
+            return_value = ((GLib.Variant) expected.return_value).get_boolean();
+        }
+        return return_value;
+    }
+
+    private inline R check_object_call<R>(ExpectedCall expected,
+                                          R default_return)
+        throws GLib.Error {
+        check_for_exception(expected);
+        R? return_object = default_return;
+        if (expected.return_object != null) {
+            return_object = (R) expected.return_object;
+        }
+        return return_object;
+    }
+
+    private inline R check_object_or_throw_call<R>(ExpectedCall expected,
+                                                   GLib.Error default_error)
+        throws GLib.Error {
+        check_for_exception(expected);
+        if (expected.return_object == null) {
+            throw default_error;
+        }
+        return expected.return_object;
+    }
+
+    private inline void check_for_exception(ExpectedCall expected)
+        throws GLib.Error {
+        if (expected.throw_error != null) {
+            throw expected.throw_error;
+        }
+    }
+
+}
+
+private interface ValaUnit.Argument {
+
+    public abstract void assert(GLib.Object object, string context)
+        throws GLib.Error;
+
+}
+
+private class ValaUnit.BoxArgument<T> : GLib.Object, Argument, TestAssertions {
+
+    private T value;
+
+    internal BoxArgument(T value) {
+        this.value = value;
+    }
+
+    public new void assert(GLib.Object object, string context)
+        throws GLib.Error {
+        assert_true(
+            object is BoxArgument,
+            "%s: Expected %s value".printf(context, this.get_type().name())
+        );
+        assert_true(this.value == ((BoxArgument<T>) object).value, context);
+    }
+
+}
+
+private class ValaUnit.IntArgument : GLib.Object, Argument, TestAssertions {
+
+    private int value;
+
+    internal IntArgument(int value) {
+        this.value = value;
+    }
+
+    public new void assert(GLib.Object object, string context)
+        throws GLib.Error {
+        assert_true(
+            object is IntArgument, "%s: Expected int value".printf(context)
+        );
+        assert_equal(((IntArgument) object).value, this.value, context);
+    }
+
+}
+
+private class ValaUnit.UintArgument : GLib.Object, Argument, TestAssertions {
+
+    private uint value;
+
+    internal UintArgument(uint value) {
+        this.value = value;
+    }
+
+    public new void assert(GLib.Object object, string context)
+        throws GLib.Error {
+        assert_true(
+            object is UintArgument, "%s: Expected uint value".printf(context)
+        );
+        assert_equal(((UintArgument) object).value, this.value, context);
+    }
+
+}
diff --git a/subprojects/vala-unit/src/test-adaptor.vala b/subprojects/vala-unit/src/test-adaptor.vala
new file mode 100644
index 000000000..3e45c7f48
--- /dev/null
+++ b/subprojects/vala-unit/src/test-adaptor.vala
@@ -0,0 +1,75 @@
+/*
+ * Copyright © 2009 Julien Peeters
+ * Copyright © 2017-2020 Michael Gratton <mike vee net>
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ *
+ * Author(s):
+ *  Julien Peeters <contact julienpeeters fr>
+ *  Michael Gratton <mike vee net>
+ */
+
+
+/**
+ * A ValaUnit to GLib testing framework adaptor.
+ */
+internal class ValaUnit.TestAdaptor : GLib.Object {
+
+
+    public string name { get; private set; }
+    public TestCase test_case { get; private set; }
+
+    private TestCase.TestMethod test;
+
+
+    public TestAdaptor(string name,
+                       owned TestCase.TestMethod test,
+                       TestCase test_case) {
+        this.name = name;
+        this.test = (owned) test;
+        this.test_case = test_case;
+    }
+
+    public void set_up(void* fixture) {
+        try {
+            this.test_case.set_up();
+        } catch (GLib.Error err) {
+            log_error(err);
+            GLib.assert_not_reached();
+        }
+    }
+
+    public void run(void* fixture) {
+        try {
+            this.test();
+        } catch (TestError.SKIPPED err) {
+            GLib.Test.skip(err.message);
+        } catch (GLib.Error err) {
+            log_error(err);
+            GLib.Test.fail();
+        }
+    }
+
+    public void tear_down(void* fixture) {
+        try {
+            this.test_case.tear_down();
+        } catch (Error err) {
+            log_error(err);
+            GLib.assert_not_reached();
+        }
+    }
+
+    private void log_error(GLib.Error err) {
+        GLib.stderr.puts(this.test_case.name);
+        GLib.stderr.putc('/');
+
+        GLib.stderr.puts(this.name);
+        GLib.stderr.puts(": ");
+
+        GLib.stderr.puts(err.message);
+        GLib.stderr.putc('\n');
+        GLib.stderr.flush();
+    }
+
+}
diff --git a/subprojects/vala-unit/src/test-assertions.vala b/subprojects/vala-unit/src/test-assertions.vala
new file mode 100644
index 000000000..b14786019
--- /dev/null
+++ b/subprojects/vala-unit/src/test-assertions.vala
@@ -0,0 +1,533 @@
+/*
+ * Copyright © 2020 Michael Gratton <mike vee net>
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace ValaUnit {
+
+    /** Error thrown when a test condition has failed */
+    public errordomain TestError {
+
+        /** Thrown when test assertion failed. */
+        FAILED,
+
+        /** Thrown when test has been skipped. */
+        SKIPPED,
+
+        /** Thrown when an assertion is not currently supported. */
+        UNSUPPORTED;
+
+    }
+
+    internal inline void assert_equal<T>(T actual,
+                                         T expected,
+                                         string? context = null)
+        throws TestError {
+        if ((actual == null && expected != null) ||
+            (actual != null && expected == null)) {
+            assert_is_not_equal(actual, expected, context);
+        }
+        if (actual != null && expected != null) {
+            // Can't just do a direct comparison here, since under the
+            // hood we'll be comparing gconstpointers, which will
+            // nearly always be incorrect
+            var type = typeof(T);
+            if (type.is_object()) {
+                if (((GLib.Object) actual) !=
+                    ((GLib.Object) expected)) {
+                    ValaUnit.assert(
+                        "%s are not equal".printf(typeof(T).name()),
+                        context
+                    );
+                }
+            } else if (type.is_enum()) {
+                assert_equal_enum<T>(actual, expected, context);
+            } else if (type == typeof(string)) {
+                assert_equal_string((string?) actual, (string?) expected, context);
+            } else if (type == typeof(int)) {
+                assert_equal_int((int?) actual, (int?) expected, context);
+            } else if (type == typeof(short)) {
+                assert_equal_short((short?) actual, (short?) expected, context);
+            } else if (type == typeof(char)) {
+                assert_equal_char((char?) actual, (char?) expected, context);
+            } else if (type == typeof(long)) {
+                assert_equal_long((long?) actual, (long?) expected, context);
+            } else if (type == typeof(int64)) {
+                assert_equal_int64((int64?) actual, (int64?) expected, context);
+            } else if (type == typeof(uint)) {
+                assert_equal_uint((uint?) actual, (uint?) expected, context);
+            } else if (type == typeof(uchar)) {
+                assert_equal_uchar((uchar?) actual, (uchar?) expected, context);
+            } else if (type == typeof(ushort)) {
+                assert_equal_ushort((ushort?) actual, (ushort?) expected, context);
+            } else if (type == typeof(ulong)) {
+                assert_equal_ulong((ulong?) actual, (ulong?) expected, context);
+            } else if (type == typeof(uint64)) {
+                assert_equal_uint64((uint64?) actual, (uint64?) expected, context);
+            } else if (type == typeof(double)) {
+                assert_equal_double((double?) actual, (double?) expected, context);
+            } else if (type == typeof(float)) {
+                assert_equal_float((float?) actual, (float?) expected, context);
+            } else if (type == typeof(bool)) {
+                assert_equal_bool((bool?) actual, (bool?) expected, context);
+            } else {
+                ValaUnit.assert(
+                    "%s is not a supported type for equality tests".printf(
+                        type.name()
+                    ),
+                    context
+                );
+            }
+        }
+    }
+
+    internal inline void assert(string message, string? context)
+        throws TestError {
+        var buf = new GLib.StringBuilder();
+        if (context != null) {
+            buf.append_c('[');
+            buf.append((string) context);
+            buf.append("] ");
+        }
+        buf.append(message);
+
+        throw new TestError.FAILED(buf.str);
+    }
+
+    /**
+     * Unpacks generics-based value types and repacks as boxed.
+     *
+     * Per GNOME/vala#564, non-boxed, non-pointer values will be
+     * passed as a pointer, where the memory address of the pointer is
+     * the actual value (!). This method works around that by casting
+     * back to a value, then boxing so that the value is allocated and
+     * passed by reference instead.
+     *
+     * This will only work when the values are not already boxed.
+     */
+    internal T box_value<T>(T value) {
+        var type = typeof(T);
+        T boxed = value;
+
+        if (type == typeof(int) || type.is_enum()) {
+            int actual = (int) value;
+            boxed = (int?) actual;
+        } else if (type == typeof(short)) {
+            short actual = (short) value;
+            boxed = (short?) actual;
+        } else if (type == typeof(char)) {
+        } else if (type == typeof(long)) {
+        } else if (type == typeof(int64)) {
+        } else if (type == typeof(uint)) {
+        } else if (type == typeof(uchar)) {
+        } else if (type == typeof(ushort)) {
+        } else if (type == typeof(ulong)) {
+        } else if (type == typeof(uint64)) {
+        } else if (type == typeof(double)) {
+        } else if (type == typeof(float)) {
+        } else if (type == typeof(bool)) {
+        }
+
+        return boxed;
+    }
+
+    internal string to_display_string<T>(T value) {
+        var type = typeof(T);
+        var display = "";
+
+        if (value == null) {
+            display = "(null)";
+        } else if (type == typeof(string)) {
+            display = "“%s”".printf((string) ((string?) value));
+        } else if (type.is_enum()) {
+            display = GLib.EnumClass.to_string(
+                typeof(T), (int) ((int?) value)
+            );
+        } else if (type == typeof(int)) {
+            display = ((int) ((int?) value)).to_string();
+        } else if (type == typeof(short)) {
+            display = ((short) ((short?) value)).to_string();
+        } else if (type == typeof(char)) {
+            display = "‘%s’".printf(((char) ((char?) value)).to_string());
+        } else if (type == typeof(long)) {
+            display = ((long) ((long?) value)).to_string();
+        } else if (type == typeof(int64)) {
+            display = ((int64) ((int64?) value)).to_string();
+        } else if (type == typeof(uint)) {
+            display = ((uint) ((uint?) value)).to_string();
+        } else if (type == typeof(uchar)) {
+            display = "‘%s’".printf(((uchar) ((uchar?) value)).to_string());
+        } else if (type == typeof(ushort)) {
+            display = ((ushort) ((ushort?) value)).to_string();
+        } else if (type == typeof(ulong)) {
+            display = ((long) ((long?) value)).to_string();
+        } else if (type == typeof(uint64)) {
+            display = ((uint64) ((uint64?) value)).to_string();
+        } else if (type == typeof(double)) {
+            display = ((double) ((double?) value)).to_string();
+        } else if (type == typeof(float)) {
+            display = ((float) ((float?) value)).to_string();
+        } else if (type == typeof(bool)) {
+            display = ((bool) ((bool?) value)).to_string();
+        } else {
+            display = type.name();
+        }
+
+        return display;
+    }
+
+    private inline void assert_is_not_equal<T>(T actual,
+                                               T expected,
+                                               string? context)
+        throws TestError {
+        assert(
+            "%s != %s".printf(
+                to_display_string(actual),
+                to_display_string(expected)
+            ),
+            context
+        );
+    }
+
+    private void assert_equal_enum<T>(T actual,
+                                      T expected,
+                                      string? context)
+    throws TestError {
+        int actual_val = (int) ((int?) actual);
+        int expected_val = (int) ((int?) expected);
+        if (actual_val != expected_val) {
+            assert_is_not_equal(actual, expected, context);
+        }
+    }
+
+    private void assert_equal_string(string? actual,
+                                    string? expected,
+                                    string? context)
+        throws TestError {
+        string actual_val = (string) actual;
+        string expected_val = (string) expected;
+        if (actual_val != expected_val) {
+            assert_is_not_equal(actual, expected, context);
+        }
+    }
+
+    private void assert_equal_int(int? actual, int? expected, string? context)
+        throws TestError {
+        int actual_val = (int) actual;
+        int expected_val = (int) expected;
+        if (actual_val != expected_val) {
+            assert_is_not_equal(actual, expected, context);
+        }
+    }
+
+    private void assert_equal_char(char? actual, char? expected, string? context)
+        throws TestError {
+        char actual_val = (char) actual;
+        char expected_val = (char) expected;
+        if (actual_val != expected_val) {
+            assert_is_not_equal(actual, expected, context);
+        }
+    }
+
+    private void assert_equal_short(short? actual, short? expected, string? context)
+        throws TestError {
+        short actual_val = (short) actual;
+        short expected_val = (short) expected;
+        if (actual_val != expected_val) {
+            assert_is_not_equal(actual, expected, context);
+        }
+    }
+
+    private void assert_equal_long(long? actual, long? expected, string? context)
+        throws TestError {
+        long actual_val = (long) actual;
+        long expected_val = (long) expected;
+        if (actual_val != expected_val) {
+            assert_is_not_equal(actual, expected, context);
+        }
+    }
+
+    private void assert_equal_int64(int64? actual, int64? expected, string? context)
+        throws TestError {
+        int64 actual_val = (int64) actual;
+        int64 expected_val = (int64) expected;
+        if (actual_val != expected_val) {
+            assert_is_not_equal(actual, expected, context);
+        }
+    }
+
+    private void assert_equal_uint(uint? actual, uint? expected, string? context)
+        throws TestError {
+        uint actual_val = (uint) actual;
+        uint expected_val = (uint) expected;
+        if (actual_val != expected_val) {
+            assert_is_not_equal(actual, expected, context);
+        }
+    }
+
+    private void assert_equal_uchar(uchar? actual, uchar? expected, string? context)
+        throws TestError {
+        uchar actual_val = (uchar) actual;
+        uchar expected_val = (uchar) expected;
+        if (actual_val != expected_val) {
+            assert_is_not_equal(actual, expected, context);
+        }
+    }
+
+    private void assert_equal_ushort(ushort? actual, ushort? expected, string? context)
+        throws TestError {
+        ushort actual_val = (ushort) actual;
+        ushort expected_val = (ushort) expected;
+        if (actual_val != expected_val) {
+            assert_is_not_equal(actual, expected, context);
+        }
+    }
+
+    private void assert_equal_ulong(ulong? actual, ulong? expected, string? context)
+        throws TestError {
+        ulong actual_val = (ulong) actual;
+        ulong expected_val = (ulong) expected;
+        if (actual_val != expected_val) {
+            assert_is_not_equal(actual, expected, context);
+        }
+    }
+
+    private void assert_equal_uint64(uint64? actual, uint64? expected, string? context)
+        throws TestError {
+        uint64 actual_val = (uint64) actual;
+        uint64 expected_val = (uint64) expected;
+        if (actual_val != expected_val) {
+            assert_is_not_equal(actual, expected, context);
+        }
+    }
+
+    private void assert_equal_float(float? actual, float? expected, string? context)
+        throws TestError {
+        float actual_val = (float) actual;
+        float expected_val = (float) expected;
+        if (actual_val != expected_val) {
+            assert_is_not_equal(actual, expected, context);
+        }
+    }
+
+    private void assert_equal_double(double? actual, double? expected, string? context)
+        throws TestError {
+        double actual_val = (double) actual;
+        double expected_val = (double) expected;
+        if (actual_val != expected_val) {
+            assert_is_not_equal(actual, expected, context);
+        }
+    }
+
+    private void assert_equal_bool(bool? actual, bool? expected, string? context)
+        throws TestError {
+        bool actual_val = (bool) actual;
+        bool expected_val = (bool) expected;
+        if (actual_val != expected_val) {
+            assert_is_not_equal(actual, expected, context);
+        }
+    }
+
+}
+
+/**
+ * Defines default test assertions.
+ *
+ * Note that {@link TestCase} implements this, so when making
+ * assertions in test methods, you can just call these directly.
+ */
+public interface ValaUnit.TestAssertions : GLib.Object {
+
+
+    /** Asserts a value is null */
+    public void assert_non_null<T>(T actual, string? context = null)
+        throws TestError {
+        if (actual == null) {
+            ValaUnit.assert(
+                "%s is null, expected non-null".printf(typeof(T).name()),
+                context
+            );
+        }
+    }
+
+    /** Asserts a value is null */
+    public void assert_null<T>(T actual, string? context = null)
+        throws TestError {
+        if (actual != null) {
+            ValaUnit.assert(
+                "%s is non-null, expected null".printf(typeof(T).name()),
+                context
+            );
+        }
+    }
+
+    /** Asserts the two given values refer to the same object or value. */
+    public void assert_equal<T>(T actual, T expected, string? context = null)
+        throws TestError {
+        ValaUnit.assert_equal(actual, expected, context);
+    }
+
+    /** Asserts the two given values refer to the same object or value. */
+    public void assert_within(double actual,
+                              double expected,
+                              double epsilon,
+                              string? context = null)
+        throws TestError {
+        if (actual > expected + epsilon || actual < expected - epsilon) {
+            ValaUnit.assert(
+                "%f is not within ±%f of %f".printf(actual, epsilon, expected),
+                context
+            );
+        }
+    }
+
+    /** Asserts a Boolean value is true. */
+    public void assert_true(bool actual, string? context = null)
+        throws TestError {
+        if (!actual) {
+            ValaUnit.assert("Is false, expected true", context);
+        }
+    }
+
+    /** Asserts a Boolean value is false. */
+    public void assert_false(bool actual, string? context = null)
+        throws TestError {
+        if (actual) {
+            ValaUnit.assert("Is true, expected false", context);
+        }
+    }
+
+    /** Asserts a collection is non-null and empty. */
+    public CollectionAssertions<string> assert_string(string? actual,
+                                                      string? context = null)
+        throws TestError {
+        if (actual == null) {
+            ValaUnit.assert("Expected a string, was null", context);
+        }
+        return new StringCollectionAssertion((string) actual, context);
+    }
+
+    /** Asserts a collection is non-null and empty. */
+    public CollectionAssertions<E> assert_array<E>(E[]? actual,
+                                                   string? context = null)
+        throws TestError {
+        if (actual == null) {
+            ValaUnit.assert("Expected an array, was null", context);
+        }
+        return new ArrayCollectionAssertion<E>((E[]) actual, context);
+    }
+
+    /** Asserts a collection is non-null and empty. */
+    public CollectionAssertions<E> assert_collection<E>(
+        Gee.Collection<E>? actual,
+        string? context = null
+    ) throws TestError {
+        if (actual == null) {
+            ValaUnit.assert("Expected a collection, was null", context);
+        }
+        return new GeeCollectionAssertion<E>(
+            (Gee.Collection<E>) actual, context
+        );
+    }
+
+    /** Asserts a comparator value is equal, that is, 0. */
+    public void assert_compare_eq(int actual, string? context = null)
+        throws TestError {
+        if (actual != 0) {
+            ValaUnit.assert(
+                "Comparison is not equal: %d".printf(actual), context
+            );
+        }
+    }
+
+    /** Asserts a comparator value is greater-than, that is, > 0. */
+    public void assert_compare_gt(int actual, string? context = null)
+        throws TestError {
+        if (actual < 0) {
+            ValaUnit.assert(
+                "Comparison is not greater than: %d".printf(actual), context
+            );
+        }
+    }
+
+    /** Asserts a comparator value is less-than, that is, < 0. */
+    public void assert_compare_lt(int actual, string? context = null)
+        throws TestError {
+        if (actual > 0) {
+            ValaUnit.assert(
+                "Comparison is not less than: %d".printf(actual), context
+            );
+        }
+    }
+
+    /**
+     * Asserts an error matches an expected type.
+     *
+     * The actual error's domain and code must be the same as that of
+     * the expected, but its message is ignored.
+     */
+    public void assert_error(GLib.Error? actual,
+                             GLib.Error expected,
+                             string? context = null) throws TestError {
+        if (actual == null) {
+            ValaUnit.assert(
+                "Expected error: %s %i, was null".printf(
+                    expected.domain.to_string(), expected.code
+                ),
+                context
+            );
+        } else {
+            var non_null = (GLib.Error) actual;
+            if (expected.domain != non_null.domain ||
+                expected.code != non_null.code) {
+                ValaUnit.assert(
+                    "Expected error: %s %i, was actually %s %i: %s".printf(
+                        expected.domain.to_string(),
+                        expected.code,
+                        non_null.domain.to_string(),
+                        non_null.code,
+                    non_null.message
+                    ),
+                    context
+                );
+            }
+        }
+    }
+
+    public void assert_no_error(GLib.Error? err, string? context = null)
+        throws TestError {
+        if (err != null) {
+            var non_null = (GLib.Error) err;
+            ValaUnit.assert(
+                "Unexpected error: %s %i: %s".printf(
+                    non_null.domain.to_string(),
+                    non_null.code,
+                    non_null.message
+                ),
+                context
+            );
+        }
+    }
+
+    // The following deliberately shadow un-prefixed GLib calls so as
+    // to get consistent behaviour when called
+
+    /**
+     * Asserts a Boolean value is true.
+     */
+    public void assert(bool actual, string? context = null)
+        throws TestError {
+        assert_true(actual, context);
+    }
+
+    /**
+     * Asserts this call is never made.
+     */
+    public void assert_not_reached(string? context = null)
+        throws TestError {
+        ValaUnit.assert("This call should not be reached", context);
+    }
+
+}
diff --git a/subprojects/vala-unit/src/test-case.vala b/subprojects/vala-unit/src/test-case.vala
new file mode 100644
index 000000000..9a7fc8882
--- /dev/null
+++ b/subprojects/vala-unit/src/test-case.vala
@@ -0,0 +1,207 @@
+/*
+ * Copyright © 2009 Julien Peeters
+ * Copyright © 2017-2020 Michael Gratton <mike vee net>
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ *
+ * Author(s):
+ *  Julien Peeters <contact julienpeeters fr>
+ *  Michael Gratton <mike vee net>
+ */
+
+
+/**
+ * The primary class for creating unit tests.
+ *
+ * A test case is a collection of related test methods.
+ *
+ * To create and run tests, extend this class with one or more test
+ * methods that implement {@link TestMethod} and call {@link add_test}
+ * for each. These may then be added to the root {@link
+ * GLib.TestSuite} or a child test suite of the root, then executed by
+ * calling {@link GLib.Test.run}.
+ *
+ * To make test assertions in test methods, call the `assert` methods
+ * on this class instead of those defined by GLib.
+ */
+public abstract class ValaUnit.TestCase : GLib.Object, TestAssertions {
+
+
+    /** The delegate that test methods must implement. */
+    public delegate void TestMethod() throws GLib.Error;
+
+
+    private class SignalWaiter : Object {
+
+        public bool was_fired = false;
+
+        public void @callback(Object source) {
+            was_fired = true;
+        }
+    }
+
+
+    /** The name of this test case. */
+    public string name { get; private set; }
+
+    /** The collection of GLib tests defined by this test case. */
+    public GLib.TestSuite suite { get; private set; }
+
+    /** Main loop context for this test case. */
+    protected GLib.MainContext main_loop {
+        get; private set; default = GLib.MainContext.default();
+    }
+
+    private TestAdaptor[] adaptors = new TestAdaptor[0];
+    private AsyncResultWaiter async_waiter;
+
+
+    /**
+     * Constructs a new named test case.
+     *
+     * The given name is used as the name of the GLib test suite that
+     * collects all tests.
+     */
+       protected TestCase(string name) {
+        this.name = name;
+        this.suite = new GLib.TestSuite(name);
+        this.async_waiter = new AsyncResultWaiter(this.main_loop);
+    }
+
+    /**
+     * Test case fixture set-up method.
+     *
+     * This method is called prior to running a test method.
+     *
+     * Test cases should override this method when they require test
+     * fixtures to be initialised before a test is run.
+     */
+    public virtual void set_up() throws GLib.Error {
+        // no-op
+    }
+
+    /**
+     * Test case fixture set-up method.
+     *
+     * This method is called after a test method is successfully run.
+     *
+     * Test cases should override this method when they require test
+     * fixtures to be destroyed after a test is run.
+     */
+    public virtual void tear_down() throws GLib.Error {
+        // no-op
+    }
+
+    /**
+     * Adds a test method to be executed as part of this test case.
+     *
+     * Adding a test method add it to {@link suite} with the given
+     * name, ensuring the {@link set_up}, test, and {@link tear_down}
+     * methods are executed when the test suite is run.
+     */
+    protected void add_test(string name, owned TestMethod test) {
+        var adaptor = new TestAdaptor(name, (owned) test, this);
+        this.adaptors += adaptor;
+
+        this.suite.add(
+            new GLib.TestCase(
+                adaptor.name,
+                adaptor.set_up,
+                adaptor.run,
+                adaptor.tear_down
+            )
+        );
+    }
+
+    /**
+     * Calls the same method on the test case's default async waiter.
+     *
+     * @see AsyncResultWaiter.async_result
+     */
+    protected AsyncResult async_result() {
+        return this.async_waiter.async_result();
+    }
+
+    /**
+     * Calls the same method on the test case's default async waiter.
+     *
+     * @see AsyncResultWaiter.async_completion
+     */
+    protected void async_completion(GLib.Object? object,
+                                    AsyncResult result) {
+        this.async_waiter.async_completion(object, result);
+    }
+
+    /**
+     * Waits for a mock object's call to be completed.
+     *
+     * This method busy waits on the test's main loop until either
+     * until {@link ExpectedCall.was_called} is true, or until the
+     * given timeout in seconds has occurred.
+     *
+     * Returns //true// if the call was made, or //false// if the
+     * timeout was reached.
+     */
+    protected bool wait_for_call(ExpectedCall call, double timeout = 1.0) {
+        GLib.Timer timer = new GLib.Timer();
+        timer.start();
+        while (!call.was_called && timer.elapsed() < timeout) {
+            this.main_loop.iteration(false);
+        }
+        return call.was_called;
+    }
+
+    /**
+     * Waits for an object's signal to be fired.
+     *
+     * This method busy waits on the test's main loop until either
+     * until the object emits the named signal, or until the given
+     * timeout in seconds has occurred.
+     *
+     * Returns //true// if the signal was fired, or //false// if the
+     * timeout was reached.
+     */
+    protected bool wait_for_signal(GLib.Object source,
+                                   string name,
+                                   double timeout = 0.5) {
+        SignalWaiter handler = new SignalWaiter();
+        ulong id = GLib.Signal.connect_swapped(
+            source, name, (GLib.Callback) handler.callback, handler
+        );
+
+        GLib.Timer timer = new GLib.Timer();
+        timer.start();
+        while (!handler.was_fired && timer.elapsed() < timeout) {
+            this.main_loop.iteration(false);
+        }
+
+        source.disconnect(id);
+        return handler.was_fired;
+    }
+
+    /**
+     * Immediately causes the current test to fail.
+     *
+     * Throws a {@link TestError.FAILED} with the given reason,
+     * terminating the test.
+     */
+    protected void fail(string? message = null) throws TestError.FAILED {
+        throw new TestError.FAILED(
+            message != null ? (string) message : "Test failed"
+        );
+    }
+
+    /**
+     * Immediately skips the rest of the current test.
+     *
+     * Throws a {@link TestError.SKIPPED} with the given reason,
+     * terminating the test.
+     */
+    protected void skip(string? message = null) throws TestError.SKIPPED {
+        throw new TestError.SKIPPED(
+            message != null ? (string) message : "Test skipped"
+        );
+    }
+
+}
diff --git a/subprojects/vala-unit/test/collection-assertions.vala 
b/subprojects/vala-unit/test/collection-assertions.vala
new file mode 100644
index 000000000..05102f961
--- /dev/null
+++ b/subprojects/vala-unit/test/collection-assertions.vala
@@ -0,0 +1,216 @@
+/*
+ * Copyright © 2020 Michael Gratton <mike vee net>
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+public class CollectionAssertions : ValaUnit.TestCase {
+
+
+
+    public CollectionAssertions() {
+        base("CollectionAssertions");
+        add_test("string_collection", string_collection);
+        add_test("string_array_collection", string_array_collection);
+        add_test("int_array_collection", int_array_collection);
+        add_test("string_gee_collection", string_gee_collection);
+        add_test("int_gee_collection", int_gee_collection);
+    }
+
+    public void string_collection() throws GLib.Error {
+        assert_string("hello", "non-empty string")
+            .is_non_empty()
+            .size(5)
+            .contains("lo")
+            .not_contains("☃")
+            .first_is("h")
+            .first_is("hell")
+            .at_index_is(1, "e")
+            .at_index_is(1, "ell");
+
+
+        assert_string("", "empty string")
+            .is_empty()
+            .size(0)
+            .contains("")
+            .not_contains("☃");
+
+        try {
+            assert_string("").is_non_empty();
+            fail("Expected ::is_non_empty to fail");
+        } catch (ValaUnit.TestError.FAILED err) {
+            // all good
+        }
+
+        try {
+            assert_string("hello").is_empty();
+            fail("Expected ::is_empty to fail");
+        } catch (ValaUnit.TestError.FAILED err) {
+            // all good
+        }
+
+        try {
+            assert_string("hello").contains("☃");
+            fail("Expected ::contains to fail");
+        } catch (ValaUnit.TestError.FAILED err) {
+            // all good
+        }
+    }
+
+    public void string_array_collection() throws GLib.Error {
+        assert_array(new string[] { "hello", "world"})
+            .is_non_empty()
+            .size(2)
+            .contains("hello")
+            .not_contains("☃")
+            .first_is("hello")
+            .at_index_is(1, "world");
+
+
+        assert_array(new string[0])
+            .is_empty()
+            .size(0)
+            .not_contains("☃");
+
+        try {
+            assert_array(new string[0]).is_non_empty();
+            fail("Expected ::is_non_empty to fail");
+        } catch (ValaUnit.TestError.FAILED err) {
+            // all good
+        }
+
+        try {
+            assert_array(new string[] { "hello", "world"}).is_empty();
+            fail("Expected ::is_empty to fail");
+        } catch (ValaUnit.TestError.FAILED err) {
+            // all good
+        }
+
+        try {
+            assert_array(new string[] { "hello", "world"}).contains("☃");
+            fail("Expected ::contains to fail");
+        } catch (ValaUnit.TestError.FAILED err) {
+            // all good
+        }
+    }
+
+    public void int_array_collection() throws GLib.Error {
+        skip("Arrays containing non-pointer values not currently supported. See GNOME/vala#964");
+        int[] array = new int[] { 42, 1337 };
+        int[] empty = new int[0];
+
+        assert_array(array)
+            .is_non_empty()
+            .size(2)
+            .contains(42)
+            .not_contains(-1)
+            .first_is(42)
+            .at_index_is(1, 1337);
+
+        assert_array(empty)
+            .is_empty()
+            .size(0)
+            .not_contains(42);
+
+        try {
+            assert_array(empty).is_non_empty();
+            fail("Expected ::is_non_empty to fail");
+        } catch (ValaUnit.TestError.FAILED err) {
+            // all good
+        }
+
+        try {
+            assert_array(array).is_empty();
+            fail("Expected ::is_empty to fail");
+        } catch (ValaUnit.TestError.FAILED err) {
+            // all good
+        }
+
+        try {
+            assert_array(array).contains(-1);
+            fail("Expected ::contains to fail");
+        } catch (ValaUnit.TestError.FAILED err) {
+            // all good
+        }
+    }
+
+    public void string_gee_collection() throws GLib.Error {
+        var strv = new string[] { "hello", "world" };
+        assert_collection(new_gee_collection(strv))
+            .is_non_empty()
+            .size(2)
+            .contains("hello")
+            .not_contains("☃")
+            .first_is("hello")
+            .at_index_is(1, "world");
+
+        assert_collection(new_gee_collection(new string[0]))
+            .is_empty()
+            .size(0)
+            .not_contains("☃");
+
+        try {
+            assert_collection(new_gee_collection(new string[0])).is_non_empty();
+            fail("Expected ::is_non_empty to fail");
+        } catch (ValaUnit.TestError.FAILED err) {
+            // all good
+        }
+
+        try {
+            assert_collection(new_gee_collection(strv)).is_empty();
+            fail("Expected ::is_empty to fail");
+        } catch (ValaUnit.TestError.FAILED err) {
+            // all good
+        }
+
+        try {
+            assert_collection(new_gee_collection(strv)).contains("☃");
+            fail("Expected ::contains to fail");
+        } catch (ValaUnit.TestError.FAILED err) {
+            // all good
+        }
+    }
+
+    public void int_gee_collection() throws GLib.Error {
+        var intv = new int[] { 42, 1337 };
+        assert_collection(new_gee_collection(intv))
+            .is_non_empty()
+            .size(2)
+            .contains(42)
+            .not_contains(-1)
+            .first_is(42)
+            .at_index_is(1, 1337);
+
+        assert_collection(new_gee_collection(new int[0]))
+            .is_empty()
+            .size(0)
+            .not_contains(42);
+
+        try {
+            assert_collection(new_gee_collection(new int[0])).is_non_empty();
+            fail("Expected ::is_non_empty to fail");
+        } catch (ValaUnit.TestError.FAILED err) {
+            // all good
+        }
+
+        try {
+            assert_collection(new_gee_collection(intv)).is_empty();
+            fail("Expected ::is_empty to fail");
+        } catch (ValaUnit.TestError.FAILED err) {
+            // all good
+        }
+
+        try {
+            assert_collection(new_gee_collection(intv)).contains(-1);
+            fail("Expected ::contains to fail");
+        } catch (ValaUnit.TestError.FAILED err) {
+            // all good
+        }
+    }
+
+    private Gee.Collection<T> new_gee_collection<T>(T[] values) {
+        return new Gee.ArrayList<T>.wrap(values);
+    }
+
+}
diff --git a/subprojects/vala-unit/test/test-assertions.vala b/subprojects/vala-unit/test/test-assertions.vala
new file mode 100644
index 000000000..1e4cebd09
--- /dev/null
+++ b/subprojects/vala-unit/test/test-assertions.vala
@@ -0,0 +1,299 @@
+/*
+ * Copyright © 2020 Michael Gratton <mike vee net>
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+public class TestAssertions : ValaUnit.TestCase {
+
+
+    private class TestObject : GLib.Object {  }
+
+    private enum TestEnum { CHECK, ONE, TWO; }
+
+    [Flags]
+    private enum TestFlags { CHECK, ONE, TWO; }
+
+    private struct TestStruct {
+        public string member;
+    }
+
+
+    public TestAssertions() {
+        base("TestAssertions");
+        add_test("gobject_equality_assertions", gobject_equality_assertions);
+        add_test("string_equality_assertions", string_equality_assertions);
+        add_test("int_equality_assertions", int_equality_assertions);
+        add_test("short_equality_assertions", short_equality_assertions);
+        add_test("long_equality_assertions", long_equality_assertions);
+        add_test("uint_equality_assertions", uint_equality_assertions);
+        add_test("float_equality_assertions", float_equality_assertions);
+        add_test("double_equality_assertions", double_equality_assertions);
+        add_test("char_equality_assertions", char_equality_assertions);
+        add_test("unichar_equality_assertions", unichar_equality_assertions);
+        add_test("enum_equality_assertions", enum_equality_assertions);
+        add_test("bool_equality_assertions", bool_equality_assertions);
+        add_test("struct_equality_assertions", struct_equality_assertions);
+        add_test("string_collection", string_collection);
+        add_test("array_collection", array_collection);
+        add_test("gee_collection", gee_collection);
+    }
+
+    public void gobject_equality_assertions() throws GLib.Error {
+        TestObject o1 = new TestObject();
+        TestObject o2 = new TestObject();
+
+        expect_equal_success(o1, o1);
+        expect_equal_failure(o1, o2);
+    }
+
+    public void string_equality_assertions() throws GLib.Error {
+        // Consts
+        expect_equal_success("foo", "foo");
+        expect_equal_failure("foo", "bar");
+
+        // Variables
+        var foo1 = "foo";
+        var foo2 = "foo";
+        var bar = "bar";
+        expect_equal_success(foo1, foo1);
+        expect_equal_success(foo1, foo2);
+        expect_equal_failure(foo1, bar);
+
+        // Boxing variations
+        expect_equal_success<string?>(foo1, foo1);
+        expect_equal_success<string?>(foo1, foo2);
+        expect_equal_failure<string?>(foo1, bar);
+        expect_equal_success<string?>("foo", "foo");
+        expect_equal_failure<string>("foo", "bar");
+        expect_equal_success((string?) foo1, (string?) foo1);
+        expect_equal_success((string?) foo1, (string?) foo2);
+        expect_equal_failure((string?) foo1, (string?) bar);
+        expect_equal_success((string?) "foo", (string?) "foo");
+        expect_equal_failure((string?) "foo", (string?) "bar");
+    }
+
+    public void int_equality_assertions() throws GLib.Error {
+        // Consts
+        expect_equal_success<int?>(42, 42);
+        expect_equal_failure<int?>(1337, -1);
+
+        // Variables
+        int forty_two_a = 42;
+        int forty_two_b = 42;
+        int l33t = 1337;
+        int neg = -1;
+        expect_equal_success<int?>(forty_two_a, forty_two_a);
+        expect_equal_success<int?>(forty_two_a, forty_two_b);
+        expect_equal_failure<int?>(l33t, neg);
+    }
+
+    public void short_equality_assertions() throws GLib.Error {
+        skip("Cannot determine if a variable is a short. See GNOME/vala#993");
+
+        // Consts
+        expect_equal_success<short?>(42, 42);
+        expect_equal_failure<short?>(1337, -1);
+
+        // Variables
+        short forty_two_a = 42;
+        short forty_two_b = 42;
+        short l33t = 1337;
+        short neg = -1;
+        expect_equal_success<short?>(forty_two_a, forty_two_a);
+        expect_equal_success<short?>(forty_two_a, forty_two_b);
+        expect_equal_failure<short?>(l33t, neg);
+    }
+
+    public void long_equality_assertions() throws GLib.Error {
+        // Consts
+        expect_equal_success<long?>(42, 42);
+        expect_equal_failure<long?>(1337, -1);
+
+        // Variables
+        long forty_two_a = 42;
+        long forty_two_b = 42;
+        long l33t = 1337;
+        long neg = -1;
+        expect_equal_success<long?>(forty_two_a, forty_two_a);
+        expect_equal_success<long?>(forty_two_a, forty_two_b);
+        expect_equal_failure<long?>(l33t, neg);
+    }
+
+    public void int64_equality_assertions() throws GLib.Error {
+        // Consts
+        expect_equal_success<int64?>(42, 42);
+        expect_equal_failure<int64?>(1337, -1);
+
+        // Variables
+        int64 forty_two_a = 42;
+        int64 forty_two_b = 42;
+        int64 l33t = 1337;
+        int64 neg = -1;
+        expect_equal_success<int64?>(forty_two_a, forty_two_a);
+        expect_equal_success<int64?>(forty_two_a, forty_two_b);
+        expect_equal_failure<int64?>(l33t, neg);
+
+        // Boundary tests
+        var max = int64.MAX;
+        var min = int64.MIN;
+        expect_equal_success<int64?>(max, max);
+        expect_equal_success<int64?>(min, min);
+        expect_equal_failure<int64?>(min, max);
+        expect_equal_failure<int64?>(max, min);
+    }
+
+    public void uint_equality_assertions() throws GLib.Error {
+        // Consts
+        expect_equal_success<uint?>(42, 42);
+        expect_equal_failure<uint?>(1337, -1);
+
+        // Variables
+        int forty_two_a = 42;
+        int forty_two_b = 42;
+        int l33t = 1337;
+        int neg = -1;
+        expect_equal_success<uint?>(forty_two_a, forty_two_a);
+        expect_equal_success<uint?>(forty_two_a, forty_two_b);
+        expect_equal_failure<uint?>(l33t, neg);
+    }
+
+    public void float_equality_assertions() throws GLib.Error {
+        // Consts
+        //
+        expect_equal_success<float?>(42.0f, 42.0f);
+        expect_equal_failure<float?>(1337.0f, (-1.0f));
+
+        // Variables
+        float forty_two_a = 42.0f;
+        float forty_two_b = 42.0f;
+        float l33t = 1337.0f;
+        float neg = -1.0f;
+        expect_equal_success<float?>(forty_two_a, forty_two_a);
+        expect_equal_success<float?>(forty_two_a, forty_two_b);
+        expect_equal_failure<float?>(l33t, neg);
+
+        // Boundary tests
+        var max = float.MAX;
+        var min = float.MIN;
+        expect_equal_success<float?>(max, max);
+        expect_equal_success<float?>(min, min);
+        expect_equal_failure<float?>(min, max);
+        expect_equal_failure<float?>(max, min);
+    }
+
+    public void double_equality_assertions() throws GLib.Error {
+        // Consts
+        //
+        expect_equal_success<double?>(42.0, 42.0);
+        expect_equal_failure<double?>(1337.0, -1.0);
+
+        // Variables
+        double forty_two_a = 42.0;
+        double forty_two_b = 42.0;
+        double l33t = 1337.0;
+        double neg = -1.0;
+        expect_equal_success<double?>(forty_two_a, forty_two_a);
+        expect_equal_success<double?>(forty_two_a, forty_two_b);
+        expect_equal_failure<double?>(l33t, neg);
+
+        // Boundary tests
+        var max = double.MAX;
+        var min = double.MIN;
+        expect_equal_success<double?>(max, max);
+        expect_equal_success<double?>(min, min);
+        expect_equal_failure<double?>(min, max);
+        expect_equal_failure<double?>(max, min);
+    }
+
+    public void char_equality_assertions() throws GLib.Error {
+        expect_equal_success<char?>('a', 'a');
+        expect_equal_failure<char?>('a', 'b');
+    }
+
+    public void unichar_equality_assertions() throws GLib.Error {
+        expect_equal_success<unichar?>('☃', '☃');
+        expect_equal_failure<unichar?>('❄', '❅');
+    }
+
+    public void enum_equality_assertions() throws GLib.Error {
+        expect_equal_success<TestEnum?>(ONE, ONE);
+        expect_equal_failure<TestEnum?>(ONE, TWO);
+    }
+
+    public void bool_equality_assertions() throws GLib.Error {
+        expect_equal_success<bool?>(true, true);
+        expect_equal_success<bool?>(false, false);
+
+        expect_equal_failure<bool?>(true, false);
+        expect_equal_failure<bool?>(false, true);
+    }
+
+    public void struct_equality_assertions() throws GLib.Error {
+        var foo = TestStruct() { member = "foo" };
+
+        expect_equal_failure<TestStruct?>(foo, foo);
+
+        // Silence the build warning about `member` being unused
+        foo.member += "";
+    }
+
+    public void string_collection() throws GLib.Error {
+        assert_string("a");
+        try {
+            assert_string(null);
+            fail("Expected null string collection assertion to fail");
+        } catch (ValaUnit.TestError.FAILED err) {
+            // all good
+        }
+    }
+
+    public void array_collection() throws GLib.Error {
+        assert_array(new string[] { "a" });
+        try {
+            assert_array<string>(null);
+            fail("Expected null array collection assertion to fail");
+        } catch (ValaUnit.TestError.FAILED err) {
+            // all good
+        }
+    }
+
+    public void gee_collection() throws GLib.Error {
+        assert_collection(new_gee_collection(new string[] { "a" }));
+        try {
+            assert_collection<Gee.ArrayList<string>>(null);
+            fail("Expected null Gee collection assertion to fail");
+        } catch (ValaUnit.TestError.FAILED err) {
+            // all good
+        }
+    }
+
+    private void expect_equal_success<T>(T actual,
+                                         T expected,
+                                         string? context = null)
+        throws GLib.Error {
+        try {
+            assert_equal(actual, expected, context);
+        } catch (ValaUnit.TestError.FAILED err) {
+            fail(@"Expected equal test to succeed: $(err.message)");
+        }
+    }
+
+    private void expect_equal_failure<T>(T actual,
+                                         T expected,
+                                         string? context = null)
+        throws GLib.Error {
+        try {
+            assert_equal(actual, expected, context);
+            fail("Expected equal test to fail");
+        } catch (ValaUnit.TestError.FAILED err) {
+            // all good
+        }
+    }
+
+    private Gee.Collection<T> new_gee_collection<T>(T[] values) {
+        return new Gee.ArrayList<T>.wrap(values);
+    }
+
+}
diff --git a/subprojects/vala-unit/test/test-driver.vala b/subprojects/vala-unit/test/test-driver.vala
new file mode 100644
index 000000000..9b6f36981
--- /dev/null
+++ b/subprojects/vala-unit/test/test-driver.vala
@@ -0,0 +1,26 @@
+/*
+ * Copyright © 2020 Michael Gratton <mike vee net>
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+int main(string[] args) {
+    Test.init(ref args);
+
+    TestSuite root = TestSuite.get_root();
+    root.add_suite(new TestAssertions().suite);
+    root.add_suite(new CollectionAssertions().suite);
+
+    MainLoop loop = new MainLoop ();
+
+    int ret = -1;
+    Idle.add(() => {
+            ret = Test.run();
+            loop.quit();
+            return false;
+        });
+
+    loop.run();
+    return ret;
+}
diff --git a/test/meson.build b/test/meson.build
index 57f33a863..736e9e8ca 100644
--- a/test/meson.build
+++ b/test/meson.build
@@ -1,13 +1,8 @@
 subdir('data')
 
-geary_test_lib_sources = [
-  'mock-object.vala',
-  'test-case.vala',
-  'test-server.vala',
-]
-
 geary_test_engine_sources = [
   'test-engine.vala',
+  'test-server.vala',
 
   # These should be included in the test lib sources, but we can't
   # since that would make the test lib depend on geary-engine.vapi,
@@ -109,25 +104,11 @@ geary_test_integration_sources = [
   'integration/smtp/client-session.vala',
 ]
 
-# Test library
-
-geary_test_lib_dependencies = [
-  gee,
-  gio
-]
-
-geary_test_lib = static_library('test-lib',
-  geary_test_lib_sources,
-  dependencies: geary_test_lib_dependencies,
-  include_directories: config_h_dir,
-  vala_args: geary_vala_args,
-  c_args: geary_c_args,
-)
-
 # Engine tests
 
 geary_test_engine_dependencies = [
-  geary_engine_internal_dep
+  geary_engine_internal_dep,
+  vala_unit_dep,
 ]
 geary_test_engine_dependencies += geary_engine_dependencies
 
@@ -142,7 +123,6 @@ endif
 
 geary_test_engine_bin = executable('test-engine',
   geary_test_engine_sources,
-  link_with: geary_test_lib,
   dependencies: geary_test_engine_dependencies,
   include_directories: config_h_dir,
   vala_args: geary_test_engine_vala_args,
@@ -152,14 +132,14 @@ geary_test_engine_bin = executable('test-engine',
 # Client tests
 
 geary_test_client_dependencies = [
-  geary_client_dep
+  geary_client_dep,
+  vala_unit_dep,
 ]
 geary_test_client_dependencies += geary_client_dependencies
 
 geary_test_client_bin = executable('test-client',
   geary_test_client_sources,
   dependencies: geary_test_client_dependencies,
-  link_with: geary_test_lib,
   include_directories: config_h_dir,
   vala_args: geary_vala_args,
   c_args: geary_c_args,
@@ -174,9 +154,9 @@ geary_test_integration_bin = executable('test-integration',
     gee,
     gio,
     gmime,
+    vala_unit_dep,
     webkit2gtk,
   ],
-  link_with: geary_test_lib,
   include_directories: config_h_dir,
   vala_args: geary_vala_args,
   c_args: geary_c_args,


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]