diff --git a/examples/wave/wave-op-var-velocity.py b/examples/wave/wave-op-var-velocity.py
index 2d8c6903dd7d311f41567a6433b73599d87e3337..758c7d38ac6269e4c7ba5672542c07500b5832d4 100644
--- a/examples/wave/wave-op-var-velocity.py
+++ b/examples/wave/wave-op-var-velocity.py
@@ -90,7 +90,8 @@ def wave_operator(dcoll, c, w):
             op.inverse_mass(dcoll,
                 flat_obj_array(
                     -op.weak_local_div(dcoll, dd_quad, c_quad*v_quad),
-                    -op.weak_local_grad(dcoll, dd_quad, c_quad*u_quad)
+                    -op.weak_local_grad(dcoll, dd_quad, c_quad*u_quad) \
+                    # pylint: disable=E1130
                     )
                 +  # noqa: W504
                 op.face_mass(dcoll,
diff --git a/grudge/op.py b/grudge/op.py
index 44b9ede79227bfc43bead56be82d1778f98af072..fe39cfa855661954614722d96dd07a97d8b441ef 100644
--- a/grudge/op.py
+++ b/grudge/op.py
@@ -260,14 +260,26 @@ def _compute_local_gradient(dcoll, vec, xyz_axis):
     )
 
 
-def local_grad(dcoll, vec):
+def local_grad(dcoll, vec, *, nested=False):
     r"""Return the element-local gradient of the volume function represented by
     *vec*.
 
     :arg dcoll: a :class:`grudge.discretization.DiscretizationCollection`.
-    :arg vec: a :class:`~meshmode.dof_array.DOFArray`
-    :returns: an object array of :class:`~meshmode.dof_array.DOFArray`\ s
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray` or object array of
+        `~meshmode.dof_array.DOFArray`
+    :arg nested: return nested object arrays instead of a single multidimensional
+        array if *vec* is non-scalar
+    :returns: an object array (possibly nested) of
+        :class:`~meshmode.dof_array.DOFArray`\ s
     """
+    if isinstance(vec, np.ndarray):
+        grad = obj_array_vectorize(
+                lambda el: local_grad(dcoll, el, nested=nested), vec)
+        if nested:
+            return grad
+        else:
+            return np.stack(grad, axis=0)
+
     return make_obj_array([_compute_local_gradient(dcoll, vec, xyz_axis)
                            for xyz_axis in range(dcoll.dim)])
 
@@ -290,15 +302,19 @@ def _div_helper(dcoll, diff_func, vecs):
         raise TypeError("argument must be an object array")
     assert vecs.dtype == object
 
-    if vecs.shape[-1] != dcoll.ambient_dim:
-        raise ValueError("last dimension of *vecs* argument must match "
-                "ambient dimension")
+    if isinstance(vecs[(0,)*vecs.ndim], np.ndarray):
+        div_shape = vecs.shape
+    else:
+        if vecs.shape[-1] != dcoll.ambient_dim:
+            raise ValueError("last dimension of *vecs* argument doesn't match "
+                    "ambient dimension")
+        div_shape = vecs.shape[:-1]
 
-    if len(vecs.shape) == 1:
+    if len(div_shape) == 0:
         return sum(diff_func(i, vec_i) for i, vec_i in enumerate(vecs))
     else:
-        result = np.zeros(vecs.shape[:-1], dtype=object)
-        for idx in np.ndindex(vecs.shape[:-1]):
+        result = np.zeros(div_shape, dtype=object)
+        for idx in np.ndindex(div_shape):
             result[idx] = sum(
                     diff_func(i, vec_i) for i, vec_i in enumerate(vecs[idx]))
         return result
@@ -407,7 +423,7 @@ def _apply_stiffness_transpose_operator(dcoll, dd_out, dd_in, vec, xyz_axis):
     )
 
 
-def weak_local_grad(dcoll, *args):
+def weak_local_grad(dcoll, *args, nested=False):
     r"""Return the element-local weak gradient of the volume function
     represented by *vec*.
 
@@ -416,8 +432,12 @@ def weak_local_grad(dcoll, *args):
     :arg dcoll: a :class:`grudge.discretization.DiscretizationCollection`.
     :arg dd: a :class:`~grudge.dof_desc.DOFDesc`, or a value convertible to one.
         Defaults to the base volume discretization if not provided.
-    :arg vec: a :class:`~meshmode.dof_array.DOFArray`
-    :returns: an object array of :class:`~meshmode.dof_array.DOFArray`\ s
+    :arg vec: a :class:`~meshmode.dof_array.DOFArray` or object array of
+        `~meshmode.dof_array.DOFArray`
+    :arg nested: return nested object arrays instead of a single multidimensional
+        array if *vec* is non-scalar
+    :returns: an object array (possibly nested) of
+        :class:`~meshmode.dof_array.DOFArray`\ s
     """
     if len(args) == 1:
         vec, = args
@@ -427,6 +447,14 @@ def weak_local_grad(dcoll, *args):
     else:
         raise TypeError("invalid number of arguments")
 
+    if isinstance(vec, np.ndarray):
+        grad = obj_array_vectorize(
+                lambda el: weak_local_grad(dcoll, dd, el, nested=nested), vec)
+        if nested:
+            return grad
+        else:
+            return np.stack(grad, axis=0)
+
     return make_obj_array(
         [_apply_stiffness_transpose_operator(dcoll,
                                              dof_desc.DD_VOLUME,
diff --git a/test/test_op.py b/test/test_op.py
new file mode 100644
index 0000000000000000000000000000000000000000..d74cdfe6ce7a7682dfb12aca506d68f53807199c
--- /dev/null
+++ b/test/test_op.py
@@ -0,0 +1,286 @@
+__copyright__ = "Copyright (C) 2021 University of Illinois Board of Trustees"
+
+__license__ = """
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+"""
+
+
+import numpy as np
+
+import meshmode.mesh.generation as mgen
+from meshmode.dof_array import thaw
+
+from pytools.obj_array import make_obj_array
+
+from grudge import op, DiscretizationCollection
+from grudge.dof_desc import DOFDesc
+
+import pytest
+from meshmode.array_context import (  # noqa
+        pytest_generate_tests_for_pyopencl_array_context
+        as pytest_generate_tests)
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+# {{{ gradient
+
+@pytest.mark.parametrize("form", ["strong", "weak"])
+@pytest.mark.parametrize("dim", [1, 2, 3])
+@pytest.mark.parametrize("order", [2, 3])
+@pytest.mark.parametrize(("vectorize", "nested"), [
+    (False, False),
+    (True, False),
+    (True, True)
+    ])
+def test_gradient(actx_factory, form, dim, order, vectorize, nested,
+        visualize=False):
+    actx = actx_factory()
+
+    from pytools.convergence import EOCRecorder
+    eoc_rec = EOCRecorder()
+
+    for n in [4, 6, 8]:
+        mesh = mgen.generate_regular_rect_mesh(
+                a=(-1,)*dim, b=(1,)*dim,
+                nelements_per_axis=(n,)*dim)
+
+        dcoll = DiscretizationCollection(actx, mesh, order=order)
+
+        def f(x):
+            result = dcoll.zeros(actx) + 1
+            for i in range(dim-1):
+                result *= actx.np.sin(np.pi*x[i])
+            result *= actx.np.cos(np.pi/2*x[dim-1])
+            return result
+
+        def grad_f(x):
+            result = make_obj_array([dcoll.zeros(actx) + 1 for _ in range(dim)])
+            for i in range(dim-1):
+                for j in range(i):
+                    result[i] *= actx.np.sin(np.pi*x[j])
+                result[i] *= np.pi*actx.np.cos(np.pi*x[i])
+                for j in range(i+1, dim-1):
+                    result[i] *= actx.np.sin(np.pi*x[j])
+                result[i] *= actx.np.cos(np.pi/2*x[dim-1])
+            for j in range(dim-1):
+                result[dim-1] *= actx.np.sin(np.pi*x[j])
+            result[dim-1] *= -np.pi/2*actx.np.sin(np.pi/2*x[dim-1])
+            return result
+
+        x = thaw(actx, op.nodes(dcoll))
+
+        if vectorize:
+            u = make_obj_array([(i+1)*f(x) for i in range(dim)])
+        else:
+            u = f(x)
+
+        def get_flux(u_tpair):
+            dd = u_tpair.dd
+            dd_allfaces = dd.with_dtag("all_faces")
+            normal = thaw(actx, op.normal(dcoll, dd))
+            u_avg = u_tpair.avg
+            if vectorize:
+                if nested:
+                    flux = make_obj_array([u_avg_i * normal for u_avg_i in u_avg])
+                else:
+                    flux = np.outer(u_avg, normal)
+            else:
+                flux = u_avg * normal
+            return op.project(dcoll, dd, dd_allfaces, flux)
+
+        dd_allfaces = DOFDesc("all_faces")
+
+        if form == "strong":
+            grad_u = (
+                op.local_grad(dcoll, u, nested=nested)
+                # No flux terms because u doesn't have inter-el jumps
+                )
+        elif form == "weak":
+            grad_u = op.inverse_mass(dcoll,
+                -op.weak_local_grad(dcoll, u, nested=nested)  # pylint: disable=E1130
+                +  # noqa: W504
+                op.face_mass(dcoll,
+                    dd_allfaces,
+                    # Note: no boundary flux terms here because u_ext == u_int == 0
+                    get_flux(op.interior_trace_pair(dcoll, u)))
+                )
+        else:
+            raise ValueError("Invalid form argument.")
+
+        if vectorize:
+            expected_grad_u = make_obj_array(
+                [(i+1)*grad_f(x) for i in range(dim)])
+            if not nested:
+                expected_grad_u = np.stack(expected_grad_u, axis=0)
+        else:
+            expected_grad_u = grad_f(x)
+
+        if visualize:
+            from grudge.shortcuts import make_visualizer
+            vis = make_visualizer(dcoll, vis_order=order if dim == 3 else dim+3)
+
+            filename = (f"test_gradient_{form}_{dim}_{order}"
+                f"{'_vec' if vectorize else ''}{'_nested' if nested else ''}.vtu")
+            vis.write_vtk_file(filename, [
+                ("u", u),
+                ("grad_u", grad_u),
+                ("expected_grad_u", expected_grad_u),
+                ], overwrite=True)
+
+        rel_linf_err = (
+            op.norm(dcoll, grad_u - expected_grad_u, np.inf)
+            / op.norm(dcoll, expected_grad_u, np.inf))
+        eoc_rec.add_data_point(1./n, rel_linf_err)
+
+    print("L^inf error:")
+    print(eoc_rec)
+    assert(eoc_rec.order_estimate() >= order - 0.5
+                or eoc_rec.max_error() < 1e-11)
+
+# }}}
+
+
+# {{{ divergence
+
+@pytest.mark.parametrize("form", ["strong", "weak"])
+@pytest.mark.parametrize("dim", [1, 2, 3])
+@pytest.mark.parametrize("order", [2, 3])
+@pytest.mark.parametrize(("vectorize", "nested"), [
+    (False, False),
+    (True, False),
+    (True, True)
+    ])
+def test_divergence(actx_factory, form, dim, order, vectorize, nested,
+        visualize=False):
+    actx = actx_factory()
+
+    from pytools.convergence import EOCRecorder
+    eoc_rec = EOCRecorder()
+
+    for n in [4, 6, 8]:
+        mesh = mgen.generate_regular_rect_mesh(
+                a=(-1,)*dim, b=(1,)*dim,
+                nelements_per_axis=(n,)*dim)
+
+        dcoll = DiscretizationCollection(actx, mesh, order=order)
+
+        def f(x):
+            result = make_obj_array([dcoll.zeros(actx) + (i+1) for i in range(dim)])
+            for i in range(dim-1):
+                result *= actx.np.sin(np.pi*x[i])
+            result *= actx.np.cos(np.pi/2*x[dim-1])
+            return result
+
+        def div_f(x):
+            result = dcoll.zeros(actx)
+            for i in range(dim-1):
+                deriv = dcoll.zeros(actx) + (i+1)
+                for j in range(i):
+                    deriv *= actx.np.sin(np.pi*x[j])
+                deriv *= np.pi*actx.np.cos(np.pi*x[i])
+                for j in range(i+1, dim-1):
+                    deriv *= actx.np.sin(np.pi*x[j])
+                deriv *= actx.np.cos(np.pi/2*x[dim-1])
+                result += deriv
+            deriv = dcoll.zeros(actx) + dim
+            for j in range(dim-1):
+                deriv *= actx.np.sin(np.pi*x[j])
+            deriv *= -np.pi/2*actx.np.sin(np.pi/2*x[dim-1])
+            result += deriv
+            return result
+
+        x = thaw(actx, op.nodes(dcoll))
+
+        if vectorize:
+            u = make_obj_array([(i+1)*f(x) for i in range(dim)])
+            if not nested:
+                u = np.stack(u, axis=0)
+        else:
+            u = f(x)
+
+        def get_flux(u_tpair):
+            dd = u_tpair.dd
+            dd_allfaces = dd.with_dtag("all_faces")
+            normal = thaw(actx, op.normal(dcoll, dd))
+            flux = u_tpair.avg @ normal
+            return op.project(dcoll, dd, dd_allfaces, flux)
+
+        dd_allfaces = DOFDesc("all_faces")
+
+        if form == "strong":
+            div_u = (
+                op.local_div(dcoll, u)
+                # No flux terms because u doesn't have inter-el jumps
+                )
+        elif form == "weak":
+            div_u = op.inverse_mass(dcoll,
+                -op.weak_local_div(dcoll, u)
+                +  # noqa: W504
+                op.face_mass(dcoll,
+                    dd_allfaces,
+                    # Note: no boundary flux terms here because u_ext == u_int == 0
+                    get_flux(op.interior_trace_pair(dcoll, u)))
+                )
+        else:
+            raise ValueError("Invalid form argument.")
+
+        if vectorize:
+            expected_div_u = make_obj_array([(i+1)*div_f(x) for i in range(dim)])
+        else:
+            expected_div_u = div_f(x)
+
+        if visualize:
+            from grudge.shortcuts import make_visualizer
+            vis = make_visualizer(dcoll, vis_order=order if dim == 3 else dim+3)
+
+            filename = (f"test_divergence_{form}_{dim}_{order}"
+                f"{'_vec' if vectorize else ''}{'_nested' if nested else ''}.vtu")
+            vis.write_vtk_file(filename, [
+                ("u", u),
+                ("div_u", div_u),
+                ("expected_div_u", expected_div_u),
+                ], overwrite=True)
+
+        rel_linf_err = (
+            op.norm(dcoll, div_u - expected_div_u, np.inf)
+            / op.norm(dcoll, expected_div_u, np.inf))
+        eoc_rec.add_data_point(1./n, rel_linf_err)
+
+    print("L^inf error:")
+    print(eoc_rec)
+    assert(eoc_rec.order_estimate() >= order - 0.5
+                or eoc_rec.max_error() < 1e-11)
+
+# }}}
+
+
+# You can test individual routines by typing
+# $ python test_grudge.py 'test_routine()'
+
+if __name__ == "__main__":
+    import sys
+    if len(sys.argv) > 1:
+        exec(sys.argv[1])
+    else:
+        pytest.main([__file__])
+
+# vim: fdm=marker